From 496c4af50e04e433aff84cc0180c31645d18d079 Mon Sep 17 00:00:00 2001 From: nico Date: Wed, 19 Aug 2026 13:59:39 +0200 Subject: [PATCH] Add Krake boss with ink, pull marks and sweep tentacles plus boss mode --- AGENTS.md | 6 +- web/index.html | 37 +++ web/src/Aura.ts | 2 + web/src/Enemy.ts | 5 + web/src/Game.ts | 203 +++++++++++++---- web/src/Golem.ts | 3 + web/src/InkBlob.ts | 74 ++++++ web/src/InkPuddle.ts | 78 +++++++ web/src/Krake.ts | 524 +++++++++++++++++++++++++++++++++++++++++++ web/src/Player.ts | 20 +- web/src/Skills.ts | 11 +- web/src/Turret.ts | 1 + 12 files changed, 911 insertions(+), 53 deletions(-) create mode 100644 web/src/InkBlob.ts create mode 100644 web/src/InkPuddle.ts create mode 100644 web/src/Krake.ts diff --git a/AGENTS.md b/AGENTS.md index 1006192..44b7122 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,9 @@ 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()` | -| `*Enemy*.ts` (Spider, Ghost, Frog, Bat, BeeSwarm, Slime, Brute/Boss, Crab, Turret) | Prozedurale Gegner-Modelle; je `update(dt, game)` | +| `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()` | +| `*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/` | @@ -47,5 +49,7 @@ npm run dev # Dev-Server mit HMR - **Skill-Arten**: Aktive = `Weapon`-Subklassen mit Cooldowns (UI-Balken in `index.html`, Update in `updateUI`). Passive = Stats in `Player.applySkillSystem` ODER Game-seitige Objekte (Aura/Boomerang), die in `syncSkillVisibility` erzeugt/zersstört und im Loop geupdated werden. - **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. - **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/index.html b/web/index.html index 1364339..5c305d7 100644 --- a/web/index.html +++ b/web/index.html @@ -37,6 +37,34 @@ 0%, 100% { opacity: 0.25; } 50% { opacity: 0.6; } } + #ink-vignette { + position: absolute; inset: 0; opacity: 0; pointer-events: none; + background: radial-gradient(ellipse at center, + rgba(120,40,180,0) 35%, rgba(120,40,180,0.4) 100%); + box-shadow: inset 0 0 140px 50px rgba(100,30,160,0.55); + } + #ink-vignette.ink { animation: ink-flash 0.5s ease-out; } + @keyframes ink-flash { + 0% { opacity: 1; } + 100% { opacity: 0; } + } + #boss-bar { + position: absolute; top: 14px; left: 50%; transform: translateX(-50%); + width: 420px; display: none; text-align: center; font-family: monospace; + } + #boss-bar-label { + color: #ffcc00; font-size: 16px; text-shadow: 1px 1px 3px #000; + margin-bottom: 4px; + } + #boss-bar-bg { + height: 18px; background: rgba(0,0,0,0.6); + border: 2px solid #aa3366; + } + #boss-bar-fill { + height: 100%; width: 100%; + background: linear-gradient(#ff5577, #bb2244); + transition: width 0.15s linear; + } #health-bar-bg { position: absolute; top: 16px; left: 16px; width: 250px; height: 20px; background: rgba(0,0,0,0.6); border: 2px solid #555; @@ -235,6 +263,11 @@
+
+
+
BOSS
+
+
100
@@ -334,6 +367,10 @@ Q, E, 1–4: Skills (werden nach Erwerb belegt)
+ diff --git a/web/src/Aura.ts b/web/src/Aura.ts index 819447e..68ad7b6 100644 --- a/web/src/Aura.ts +++ b/web/src/Aura.ts @@ -76,6 +76,8 @@ export class Aura { const dist = enemy.body.position.distanceTo(center); if (dist > radius + 0.4) continue; enemy.takeDamage(damage, game, false, center); + // Stationaere Gegner (Bosse, Tuerme) werden nur geschaedigt, nicht geschoben + if (enemy.immovable) continue; const dir = new THREE.Vector3() .subVectors(enemy.body.position, center); dir.y = 0; diff --git a/web/src/Enemy.ts b/web/src/Enemy.ts index 5f6362a..a65ecd0 100644 --- a/web/src/Enemy.ts +++ b/web/src/Enemy.ts @@ -9,11 +9,16 @@ export abstract class Enemy { knockback = 0; rootTime = 0; health = 100; + maxHealth = 100; + isBoss = false; + displayName = ''; speed = 6; xp = 10; contactDps = 25; contactRadius = 2; guaranteedDrop = false; + spawnAtCenter = false; + immovable = false; grace = 0; mixer: THREE.AnimationMixer | null = null; protected clips: Map = new Map(); diff --git a/web/src/Game.ts b/web/src/Game.ts index 39ca611..0422a3d 100644 --- a/web/src/Game.ts +++ b/web/src/Game.ts @@ -9,6 +9,7 @@ import { BeeSwarm } from './BeeSwarm'; import { Slime } from './Slime'; import { Brute } from './Brute'; import { Golem } from './Golem'; +import { Krake } from './Krake'; import { Crab } from './Crab'; import { Turret } from './Turret'; import { EnemyProjectile } from './EnemyProjectile'; @@ -74,8 +75,11 @@ export class Game { spawns = 0; killed = 0; - // Boss-Wellen: Welle -> Boss-Factory (aktuell nur Welle 10; 20/30 folgen) - private bossWaves = new Map Enemy>([[10, () => new Golem()]]); + // Boss-Wellen: Welle -> Boss (Name fuer Boss-Modus-UI, Factory fuer Spawn) + private bossWaves = new Map Enemy }>([ + [10, { name: 'Golem', spawn: () => new Golem() }], + [20, { name: 'Krake', spawn: () => new Krake() }], + ]); private bossFightActive = false; // Debug hitzone visualization @@ -104,6 +108,9 @@ export class Game { private uiGameOverStats!: HTMLElement; private uiMainMenu!: HTMLElement; private uiSeedInput!: HTMLInputElement; + private uiBossMode!: HTMLElement; + private uiBossModeButtons!: HTMLElement; + private cheatBuffer = ''; private uiLevelLabel!: HTMLElement; private uiSeedLabel!: HTMLElement; private uiXpBarFill!: HTMLElement; @@ -118,6 +125,10 @@ export class Game { private uiOfferDescs: HTMLElement[] = []; private uiDamageVignette!: HTMLElement; private uiLowHpVignette!: HTMLElement; + private uiInkVignette!: HTMLElement; + private uiBossBar!: HTMLElement; + private uiBossBarFill!: HTMLElement; + private uiBossBarLabel!: HTMLElement; private uiTrapContainer!: HTMLElement; private uiTrapCD!: HTMLElement; private uiGasContainer!: HTMLElement; @@ -218,6 +229,13 @@ export class Game { this.uiConfirmBtn.addEventListener('click', () => this.confirmOffer()); this.uiDamageVignette = document.getElementById('damage-vignette')!; this.uiLowHpVignette = document.getElementById('low-hp-vignette')!; + this.uiInkVignette = document.getElementById('ink-vignette')!; + this.uiBossBar = document.getElementById('boss-bar')!; + this.uiBossBarFill = document.getElementById('boss-bar-fill')!; + this.uiBossBarLabel = document.getElementById('boss-bar-label')!; + this.uiBossMode = document.getElementById('boss-mode')!; + this.uiBossModeButtons = document.getElementById('boss-mode-buttons')!; + this.setupBossModeUI(); this.uiTrapContainer = document.getElementById('trap-cooldown')!; this.uiTrapCD = document.querySelector('#trap-cooldown .cooldown-fill')!; this.uiGasContainer = document.getElementById('gas-cooldown')!; @@ -278,13 +296,26 @@ export class Game { e.preventDefault(); return; } - if (e.code === 'Space' && this.state === 'playing') { + if (e.code === 'Space' && this.state === 'playing' && this.player.pullTime <= 0) { this.player.jump(); e.preventDefault(); } if (e.code === 'KeyH') { this.showHitzones = !this.showHitzones; } + // Cheat-Code "idkfa" im Hauptmenue: Boss-Modus freischalten + if (this.state === 'menu') { + const active = document.activeElement; + const typing = active instanceof HTMLInputElement + || active instanceof HTMLTextAreaElement; + if (!typing) { + this.cheatBuffer = (this.cheatBuffer + e.key).slice(-5).toLowerCase(); + if (this.cheatBuffer === 'idkfa') { + this.cheatBuffer = ''; + this.showBossMode(); + } + } + } const slotIdx = HOTBAR_KEY_CODES.indexOf(e.code); if (slotIdx >= 0 && this.state === 'playing' && this.hotbar[slotIdx]) { this.triggerHotbarSkill(this.hotbar[slotIdx]!); @@ -498,7 +529,11 @@ export class Game { } } this.resetWorld(); - this.logState('playing', 'startGame'); + this.beginRun(); + } + + private beginRun() { + this.logState('playing', 'beginRun'); this.state = 'playing'; this.paused = false; this.hidePauseOverlay(); @@ -514,6 +549,51 @@ export class Game { this.blurFocus(); } + private setupBossModeUI() { + this.uiBossModeButtons.innerHTML = ''; + for (const [wave, entry] of this.bossWaves) { + const btn = document.createElement('button'); + btn.className = 'menu-button'; + btn.textContent = `${entry.name} (Welle ${wave})`; + btn.style.fontSize = '14px'; + btn.style.padding = '8px 20px'; + btn.addEventListener('click', () => this.startBossFight(wave)); + this.uiBossModeButtons.appendChild(btn); + } + } + + private showBossMode() { + this.uiBossMode.style.opacity = '1'; + this.uiBossMode.style.pointerEvents = 'auto'; + this.uiBossMode.style.visibility = 'visible'; + playSound('spawn', 0.7); + } + + startBossFight(wave: number) { + if (this.state === 'playing' || this.state === 'levelup') return; + this.seed = Math.floor(Math.random() * 1000000); + this.resetWorld(); + this.spawns = wave; + this.grantRandomSkills(wave); + this.spawnBossForWave(wave); + this.bossFightActive = true; + this.spawnTimer = 0; + this.beginRun(); + } + + private grantRandomSkills(wave: number) { + const picks = Math.round(wave * 0.7); + for (let i = 0; i < picks; i++) { + const offers = this.skillSystem.rollOffers(); + if (offers.length === 0) break; + const pick = offers[Math.floor(Math.random() * offers.length)]; + this.skillSystem.apply(pick.id); + this.assignHotbarSkill(pick.id); + } + this.player.applySkillSystem(this.skillSystem); + this.syncSkillVisibility(); + } + private blurFocus() { const el = document.activeElement; if (el instanceof HTMLElement) el.blur(); @@ -728,15 +808,19 @@ export class Game { this.uiConfirmBtn.disabled = false; } + private assignHotbarSkill(skillId: string) { + if (HOTBAR_SKILLS.includes(skillId) && !this.hotbar.includes(skillId)) { + const free = this.hotbar.indexOf(null); + if (free >= 0) this.hotbar[free] = skillId; + } + } + private confirmOffer() { if (this.state !== 'levelup' || this.selectedOffer < 0) return; const offer = this.offers[this.selectedOffer]; this.selectedOffer = -1; this.skillSystem.apply(offer.id); - if (HOTBAR_SKILLS.includes(offer.id) && !this.hotbar.includes(offer.id)) { - const free = this.hotbar.indexOf(null); - if (free >= 0) this.hotbar[free] = offer.id; - } + this.assignHotbarSkill(offer.id); this.player.applySkillSystem(this.skillSystem); this.syncSkillVisibility(); this.blurFocus(); @@ -841,11 +925,12 @@ export class Game { } private spawnBossForWave(wave: number) { - const factory = this.bossWaves.get(wave); - if (!factory) return; - const x = (Math.random() - 0.5) * 80; - const z = (Math.random() - 0.5) * 80; - this.spawnEnemyInstance(factory(), x, z, 0.15); + 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; + this.spawnEnemyInstance(enemy, x, z, 0.15); playSound('spawn', 0.8); } @@ -908,49 +993,60 @@ export class Game { const camLeft = new THREE.Vector3(); camLeft.crossVectors(new THREE.Vector3(0, 1, 0), camFwd).normalize(); - const moveDir = new THREE.Vector3(); - if (this.keys.has('KeyW') || this.keys.has('ArrowUp')) moveDir.add(camFwd); - if (this.keys.has('KeyS') || this.keys.has('ArrowDown')) moveDir.sub(camFwd); - if (this.keys.has('KeyA') || this.keys.has('ArrowLeft')) moveDir.add(camLeft); - if (this.keys.has('KeyD') || this.keys.has('ArrowRight')) moveDir.sub(camLeft); - - let speed = this.player.stats.moveSpeed; + const p = this.player; + let speed = p.stats.moveSpeed; + if (p.slowTimer > 0) speed *= p.slowFactor; let isMoving = false; - if (moveDir.length() > 0) { - moveDir.normalize(); - this.player.velocity.set(moveDir.x * speed, 0, moveDir.z * speed); - isMoving = true; + + if (p.pullTime > 0) { + // Tentakel-Griff: Spieler wird komplett zum Boss gezogen + p.pullTime -= dt; + p.velocity.copy(p.pullDir).multiplyScalar(20); + p.onGround = true; + p.body.position.y = 0.5; } else { - this.player.velocity.set(0, 0, 0); + const moveDir = new THREE.Vector3(); + if (this.keys.has('KeyW') || this.keys.has('ArrowUp')) moveDir.add(camFwd); + if (this.keys.has('KeyS') || this.keys.has('ArrowDown')) moveDir.sub(camFwd); + if (this.keys.has('KeyA') || this.keys.has('ArrowLeft')) moveDir.add(camLeft); + if (this.keys.has('KeyD') || this.keys.has('ArrowRight')) moveDir.sub(camLeft); + + if (moveDir.length() > 0) { + moveDir.normalize(); + p.velocity.set(moveDir.x * speed, 0, moveDir.z * speed); + isMoving = true; + } else { + p.velocity.set(0, 0, 0); + } } // Play walk/stand animations - if (isMoving && this.player.getCurrentAnimation() !== 'walk') { - this.player.playAnimation('walk'); - } else if (!isMoving && this.player.getCurrentAnimation() !== 'stand') { - this.player.playAnimation('stand'); + if (isMoving && p.getCurrentAnimation() !== 'walk') { + p.playAnimation('walk'); + } else if (!isMoving && p.getCurrentAnimation() !== 'stand') { + p.playAnimation('stand'); } // Jump / gravity - if (!this.player.onGround) { - this.player.jumpVelocity -= 20 * dt; - this.player.body.position.y += this.player.jumpVelocity * dt; - if (this.player.body.position.y <= 0.5) { - this.player.body.position.y = 0.5; - this.player.jumpVelocity = 0; - this.player.onGround = true; + if (!p.onGround && p.pullTime <= 0) { + p.jumpVelocity -= 20 * dt; + p.body.position.y += p.jumpVelocity * dt; + if (p.body.position.y <= 0.5) { + p.body.position.y = 0.5; + p.jumpVelocity = 0; + p.onGround = true; } } // Apply horizontal movement - this.player.body.position.x += this.player.velocity.x * dt; - this.player.body.position.z += this.player.velocity.z * dt; - this.clampToArena(this.player.body.position); + p.body.position.x += p.velocity.x * dt; + p.body.position.z += p.velocity.z * dt; + this.clampToArena(p.body.position); // Look at ground point - this.player.body.lookAt( + p.body.lookAt( this.groundPoint.x, - this.player.body.position.y, + p.body.position.y, this.groundPoint.z ); } @@ -1042,6 +1138,17 @@ export class Game { ? '' : `Seed: ${this.seed}`; + // Boss-Healthbar (sichtbar, solange ein Boss lebt) + const boss = this.enemies.find(e => e.isBoss && !e.dead); + if (boss) { + this.uiBossBar.style.display = 'block'; + this.uiBossBarLabel.textContent = boss.displayName; + this.uiBossBarFill.style.width = + `${Math.max(0, (boss.health / boss.maxHealth) * 100)}%`; + } else { + this.uiBossBar.style.display = 'none'; + } + const xpPct = this.skillSystem.xpProgress(); this.uiXpBarFill.style.width = `${Math.min(1, xpPct) * 100}%`; this.uiXpBarLabel.textContent = this.skillSystem.hasAnyUpgradeLeft() @@ -1093,6 +1200,13 @@ export class Game { ); } + triggerInkVignette() { + const el = this.uiInkVignette; + el.classList.remove('ink'); + void el.offsetWidth; + el.classList.add('ink'); + } + onPlayerHurt(sourcePos: THREE.Vector3) { const el = this.uiDamageVignette; el.classList.remove('hurt'); @@ -1335,12 +1449,11 @@ export class Game { } if (this.state === 'playing' || this.state === 'levelup') { - const leveled = this.skillSystem.addXp(enemy.xp); - if (leveled) { + const levels = this.skillSystem.addXp(enemy.xp); + if (levels > 0) { + this.pendingPicks += levels; if (this.state === 'playing') { this.openLevelUp(); - } else { - this.pendingPicks++; } } } diff --git a/web/src/Golem.ts b/web/src/Golem.ts index c36b1ec..321d15b 100644 --- a/web/src/Golem.ts +++ b/web/src/Golem.ts @@ -6,6 +6,9 @@ export class Golem extends Brute { constructor() { super(); this.health = 800; + this.maxHealth = 800; + this.isBoss = true; + this.displayName = 'Golem'; this.speed = 3; this.xp = 80; this.contactDps = 50; diff --git a/web/src/InkBlob.ts b/web/src/InkBlob.ts new file mode 100644 index 0000000..bbe87b8 --- /dev/null +++ b/web/src/InkBlob.ts @@ -0,0 +1,74 @@ +import * as THREE from 'three'; +import type { Game } from './Game'; + +const SPEED = 8; +const PLAYER_PROXIMITY = 1.4; + +export class InkBlob { + body: THREE.Group; + target: THREE.Vector3; + private vel: THREE.Vector3; + private life = 6; + dead = false; + nearPlayer = false; + + constructor(from: THREE.Vector3, target: THREE.Vector3) { + this.target = target.clone(); + this.body = new THREE.Group(); + this.body.position.copy(from); + + this.vel = new THREE.Vector3().subVectors(target, from); + this.vel.y = 0; + const dist = this.vel.length(); + if (dist > 0.01) { + this.vel.normalize().multiplyScalar(SPEED); + } + + const glowMat = new THREE.MeshBasicMaterial({ + color: 0xaa44ff, + transparent: true, + opacity: 0.5, + blending: THREE.AdditiveBlending, + depthWrite: false, + }); + const glow = new THREE.Mesh(new THREE.SphereGeometry(0.5, 12, 12), glowMat); + this.body.add(glow); + + const coreMat = new THREE.MeshToonMaterial({ color: 0x3a1060 }); + const core = new THREE.Mesh(new THREE.SphereGeometry(0.28, 10, 10), coreMat); + this.body.add(core); + } + + update(dt: number, game: Game): boolean { + this.life -= dt; + this.body.position.x += this.vel.x * dt; + this.body.position.z += this.vel.z * dt; + this.body.position.y = 1.2 + Math.sin(this.life * 4) * 0.1; + + if (game.player.health > 0 && !this.nearPlayer) { + const dist = this.body.position.distanceTo(game.player.body.position); + if (dist < PLAYER_PROXIMITY) { + this.nearPlayer = true; + game.triggerInkVignette(); + } + } + + const dx = this.target.x - this.body.position.x; + const dz = this.target.z - this.body.position.z; + if (this.life <= 0 || dx * dx + dz * dz < 0.25) { + 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(); + } + } + } +} diff --git a/web/src/InkPuddle.ts b/web/src/InkPuddle.ts new file mode 100644 index 0000000..9a952c9 --- /dev/null +++ b/web/src/InkPuddle.ts @@ -0,0 +1,78 @@ +import * as THREE from 'three'; +import type { Game } from './Game'; + +const TICK = 0.5; +const TICK_DAMAGE = 4; +const SLOW_REFRESH = 0.15; + +export class InkPuddle { + body: THREE.Group; + private radius: number; + private life: number; + private tickTimer = 0; + private puffs: THREE.Mesh[] = []; + + constructor(position: THREE.Vector3, radius = 3.5, duration = 6) { + this.radius = radius; + this.life = duration; + this.body = new THREE.Group(); + this.body.position.set(position.x, 0.1, position.z); + + const puffGeom = new THREE.SphereGeometry(1, 12, 10); + const puffMat = new THREE.MeshToonMaterial({ + color: 0x552277, + transparent: true, + opacity: 0.35, + 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) * this.radius * 0.4, + 0.3 + Math.random() * 0.3, + Math.sin(a) * this.radius * 0.4 + ); + puff.scale.setScalar(this.radius * (0.22 + 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.35 * 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 < this.radius) { + game.player.slowTimer = SLOW_REFRESH; + this.tickTimer -= dt; + if (this.tickTimer <= 0) { + this.tickTimer = TICK; + game.player.damage(TICK_DAMAGE, this.body.position); + game.triggerInkVignette(); + } + } + } + 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/Krake.ts b/web/src/Krake.ts new file mode 100644 index 0000000..107eb00 --- /dev/null +++ b/web/src/Krake.ts @@ -0,0 +1,524 @@ +import * as THREE from 'three'; +import type { Game } from './Game'; +import { Enemy } from './Enemy'; +import { playSound } from './SoundManager'; +import { InkBlob } from './InkBlob'; +import { InkPuddle } from './InkPuddle'; + +const MAX_HEALTH = 3200; +const INK_COOLDOWN = 4.5; +const PULL_COOLDOWN = 7.5; +const PULL_MARK_COUNT = 5; +const PULL_MARK_RADIUS = 2.2; +const PULL_MARK_DIST = 6.5; +const PULL_CHARGE_TIME = 2.5; +const PULL_DAMAGE = 25; +const PULL_TIME = 0.6; +const SLAM_TIME = 0.5; +const SWEEP_RADIUS = 11; +const SWEEP_DURATION = 3.6; +const SWEEP_HIT_WIDTH = 1.2; +const SWEEP_DAMAGE = 25; +const ENRAGE_RATIO = 0.3; +const INK_RADIUS = 3.5; +const INK_DURATION = 6; + +export class Krake extends Enemy { + spawnAtCenter = true; + private model: THREE.Group; + private game: Game | null = null; + private waveTime = 0; + private tentacles: THREE.Group[] = []; + private inkTimer = 2; + private pullTimer = 3.5; + private sweepTimer = 5; + private enraged = false; + private effectsReady = false; + + private slamState: 'none' | 'grab' | 'slam' = 'none'; + private slamChain!: THREE.Group; + private slamTime = 0; + private slamRetractFrom = 1; + private pullState: 'idle' | 'charge' = 'idle'; + private pullChargeTime = 0; + private markOffsetAngle = 0; + private pullMarks: { + group: THREE.Group; + ringMat: THREE.MeshBasicMaterial; + discMat: THREE.MeshBasicMaterial; + }[] = []; + + private sweepTents: { group: THREE.Group; angle: number; dir: number; progress: number }[] = []; + private sweepActive = false; + private sweepHit = false; + + private inkBlobs: InkBlob[] = []; + private puddles: InkPuddle[] = []; + private skinMat!: THREE.MeshToonMaterial; + private darkMat!: THREE.MeshToonMaterial; + + constructor() { + super(); + this.health = MAX_HEALTH; + this.maxHealth = MAX_HEALTH; + this.isBoss = true; + this.displayName = 'Krake'; + this.immovable = true; + this.speed = 0; + this.xp = 150; + this.contactDps = 0; + this.contactRadius = 0; + this.guaranteedDrop = true; + + this.model = new THREE.Group(); + this.fadeOutModel = this.model; + this.body.add(this.model); + + const skinMat = new THREE.MeshToonMaterial({ color: 0x8a4a9a }); + const darkMat = new THREE.MeshToonMaterial({ color: 0x5a2a6a }); + const eyeMat = new THREE.MeshBasicMaterial({ color: 0xffee44 }); + this.skinMat = skinMat; + this.darkMat = darkMat; + + // Kopf + const head = new THREE.Mesh(new THREE.SphereGeometry(2.2, 24, 18), skinMat); + head.scale.set(1, 0.85, 1); + head.position.y = 3.1; + head.castShadow = true; + this.registerFadeMesh(head); + this.model.add(head); + + // Augen auf +Z (Blickrichtung) + for (const side of [-1, 1]) { + const eye = new THREE.Mesh(new THREE.SphereGeometry(0.4, 10, 8), eyeMat); + eye.position.set(side * 0.8, 3.45, 1.9); + this.registerFadeMesh(eye); + this.model.add(eye); + } + + // Tentakel um den Kopf herum + for (let i = 0; i < 8; i++) { + const chain = new THREE.Group(); + const a = (i / 8) * Math.PI * 2; + for (let j = 0; j < 7; j++) { + const seg = new THREE.Mesh( + new THREE.SphereGeometry(0.42 - j * 0.04, 12, 10), + j % 2 === 0 ? skinMat : darkMat + ); + seg.position.set(j * 0.55, -j * 0.28, 0); + this.registerFadeMesh(seg); + chain.add(seg); + } + chain.position.set(Math.cos(a) * 1.7, 1.1, Math.sin(a) * 1.7); + chain.rotation.y = -a; + this.model.add(chain); + this.tentacles.push(chain); + } + + // Weltraum-Effekte (Pull-Marken, Slam-Tentakel, Sweep-Tentakel) + this.buildEffects(); + } + + private buildEffects() { + // 5 rote Boden-Marken (Arme) rund um den Oktopus + for (let i = 0; i < PULL_MARK_COUNT; i++) { + const ringMat = new THREE.MeshBasicMaterial({ + color: 0xff3333, + transparent: true, + opacity: 0, + side: THREE.DoubleSide, + depthWrite: false, + }); + const discMat = new THREE.MeshBasicMaterial({ + color: 0xff2222, + transparent: true, + opacity: 0, + depthWrite: false, + }); + const ring = new THREE.Mesh( + new THREE.RingGeometry(PULL_MARK_RADIUS - 0.15, PULL_MARK_RADIUS + 0.15, 32), + ringMat + ); + ring.rotation.x = -Math.PI / 2; + ring.position.y = 0.05; + const disc = new THREE.Mesh(new THREE.CircleGeometry(PULL_MARK_RADIUS, 32), discMat); + disc.rotation.x = -Math.PI / 2; + disc.position.y = 0.03; + const group = new THREE.Group(); + group.add(ring, disc); + group.visible = false; + this.pullMarks.push({ group, ringMat, discMat }); + } + + this.slamChain = new THREE.Group(); + for (let j = 1; j <= 10; j++) { + const seg = new THREE.Mesh( + new THREE.SphereGeometry(0.3, 8, 6), + new THREE.MeshToonMaterial({ color: 0x7a3a8a }) + ); + seg.position.set(0, 0, j * 0.5); + this.slamChain.add(seg); + } + this.slamChain.visible = false; + + // Kreisende Sweep-Tentakel (dicker roter Arm mit Saugnaepfen, ueber den man springen kann) + // Zwei Exemplare: Phase 2 nutzt beide in entgegengesetzte Richtungen + for (let i = 0; i < 2; i++) { + const group = this.buildSweepTentacle(); + group.visible = false; + this.sweepTents.push({ group, angle: 0, dir: i === 0 ? 1 : -1, progress: 0 }); + } + } + + private buildSweepTentacle(): THREE.Group { + const sweepMat = new THREE.MeshToonMaterial({ color: 0xdd4455 }); + const cupMat = new THREE.MeshToonMaterial({ color: 0xffb3a0 }); + const cupGeom = new THREE.SphereGeometry(1, 10, 6); + const group = new THREE.Group(); + for (let j = 1; j <= 18; j++) { + const r = Math.max(0.22, 0.42 - j * 0.012); + const seg = new THREE.Mesh(new THREE.SphereGeometry(r, 12, 10), sweepMat); + seg.position.set(j * 0.6, 0.3, 0); + group.add(seg); + + // Saugnapf: flache Scheibe, abwechselnd links/rechts + const side = j % 2 === 0 ? 1 : -1; + const cup = new THREE.Mesh(cupGeom, cupMat); + cup.scale.set(r * 0.5, r * 0.18, r * 0.5); + cup.position.set(j * 0.6, 0.3, side * (r + 0.02)); + group.add(cup); + } + return group; + } + + playAnim() { + // Prozedurale Optik + } + + updateAnimation(dt: number) { + this.updateFadeDeath(dt, { duration: 1.2, sink: 0.5 }); + } + + private ensureEffects(scene: THREE.Scene) { + if (this.effectsReady) return; + for (const mark of this.pullMarks) scene.add(mark.group); + scene.add(this.slamChain); + for (const tent of this.sweepTents) scene.add(tent.group); + this.effectsReady = true; + } + + update(dt: number, game: Game) { + this.game = game; + this.ensureEffects(game.scene); + this.waveTime += dt; + + // Tentakel wippen + for (let i = 0; i < this.tentacles.length; i++) { + this.tentacles[i].rotation.z = Math.sin(this.waveTime * 2.2 + i * 0.9) * 0.25; + } + + // Zum Spieler drehen + const playerPos = game.player.body.position; + const toPlayer = new THREE.Vector3().subVectors(playerPos, this.body.position); + toPlayer.y = 0; + if (toPlayer.length() > 0.1) { + this.body.lookAt( + this.body.position.x + toPlayer.x, + this.body.position.y, + this.body.position.z + toPlayer.z + ); + } + + // Enrage unter 50% HP + if (!this.enraged && this.health < MAX_HEALTH * ENRAGE_RATIO) { + this.enraged = true; + // Phase 2: Krake wird dunkelrot + this.skinMat.color.set(0x8a2233); + this.darkMat.color.set(0x551520); + playSound('damage', 0.8); + } + const inkCd = this.enraged ? INK_COOLDOWN * 0.6 : INK_COOLDOWN; + const pullCd = this.enraged ? PULL_COOLDOWN * 0.65 : PULL_COOLDOWN; + + // Tinte spucken + this.inkTimer -= dt; + if (this.inkTimer <= 0) { + this.inkTimer = inkCd; + this.spitInk(game); + } + + // Tentakel-Griff + if (this.pullState === 'idle') { + this.pullTimer -= dt; + if (this.pullTimer <= 0) { + this.pullTimer = pullCd; + this.startPullCharge(game); + } + } else { + this.updatePullCharge(dt, game); + } + if (this.slamState === 'grab') { + // Tentakel bleibt am Spieler haengen, solange er gezogen wird + if (game.player.pullTime > 0) { + const playerPos = game.player.body.position; + this.slamChain.position.set(this.body.position.x, 3.2, this.body.position.z); + this.slamChain.lookAt(playerPos.x, 1, playerPos.z); + const dist = Math.max(1, Math.hypot( + playerPos.x - this.body.position.x, + playerPos.z - this.body.position.z + )); + this.slamChain.scale.set(1, 1, Math.min(dist / 5, 2.5)); + } else { + // Zug vorbei -> Tentakel zieht sich zurueck + this.slamRetractFrom = this.slamChain.scale.z; + this.slamState = 'slam'; + this.slamTime = SLAM_TIME; + } + } else if (this.slamState === 'slam') { + this.updateSlam(dt); + } + + // Tentakel-Sweep + this.updateSweep(dt, game); + + // Tinten-Blobs aktualisieren + for (let i = this.inkBlobs.length - 1; i >= 0; i--) { + const blob = this.inkBlobs[i]; + if (!blob.update(dt, game)) { + this.inkBlobs.splice(i, 1); + const puddle = new InkPuddle( + blob.body.position, + this.enraged ? INK_RADIUS + 0.5 : INK_RADIUS, + INK_DURATION + ); + game.scene.add(puddle.body); + this.puddles.push(puddle); + blob.dispose(); + playSound('explosion', 0.4); + } + } + + // Tintenpfützen aktualisieren + for (let i = this.puddles.length - 1; i >= 0; i--) { + const puddle = this.puddles[i]; + if (!puddle.update(dt, game)) { + this.puddles.splice(i, 1); + puddle.dispose(); + } + } + } + + private spitInk(game: Game) { + const from = new THREE.Vector3(this.body.position.x, 3.0, this.body.position.z); + const target = game.player.body.position.clone(); + target.y = 0; + + if (this.enraged) { + const t2 = target.clone().add( + new THREE.Vector3((Math.random() - 0.5) * 8, 0, (Math.random() - 0.5) * 8) + ); + const blob2 = new InkBlob(from, t2); + game.scene.add(blob2.body); + this.inkBlobs.push(blob2); + } + + const blob = new InkBlob(from, target); + game.scene.add(blob.body); + this.inkBlobs.push(blob); + playSound('spawn', 0.5); + } + + private startPullCharge(game: Game) { + this.pullState = 'charge'; + this.pullChargeTime = PULL_CHARGE_TIME; + this.markOffsetAngle = Math.random() * Math.PI * 2; + const cx = this.body.position.x; + const cz = this.body.position.z; + for (let i = 0; i < PULL_MARK_COUNT; i++) { + const a = this.markOffsetAngle + (i / PULL_MARK_COUNT) * Math.PI * 2; + const mark = this.pullMarks[i]; + mark.group.position.set( + cx + Math.cos(a) * PULL_MARK_DIST, + 0.06, + cz + Math.sin(a) * PULL_MARK_DIST + ); + mark.group.visible = true; + } + playSound('spawn', 0.8); + } + + private updatePullCharge(dt: number, game: Game) { + this.pullChargeTime -= dt; + const pulse = Math.abs(Math.sin(this.waveTime * 9)); + for (const mark of this.pullMarks) { + mark.ringMat.opacity = 0.3 + 0.5 * pulse; + mark.discMat.opacity = 0.1 + 0.08 * pulse; + } + + if (this.pullChargeTime <= 0) { + this.pullState = 'idle'; + const playerPos = game.player.body.position; + + // Spieler in einer Marke? -> heranziehen + let grabbed = false; + for (const mark of this.pullMarks) { + const dx = playerPos.x - mark.group.position.x; + const dz = playerPos.z - mark.group.position.z; + if (Math.hypot(dx, dz) < PULL_MARK_RADIUS) { + grabbed = true; + break; + } + } + for (const mark of this.pullMarks) { + mark.group.visible = false; + mark.ringMat.opacity = 0; + mark.discMat.opacity = 0; + } + + if (grabbed && playerPos.y <= 0.8) { + // Slam-Tentakel greift den Spieler und bleibt haengen + this.slamChain.position.set(this.body.position.x, 3.2, this.body.position.z); + this.slamChain.lookAt(playerPos.x, 1, playerPos.z); + this.slamChain.scale.set(1, 1, 1); + this.slamChain.visible = true; + this.slamState = 'grab'; + + game.player.damage(PULL_DAMAGE, this.body.position); + const dir = new THREE.Vector3().subVectors(this.body.position, playerPos); + dir.y = 0; + if (dir.lengthSq() < 0.01) dir.set(0, 0, 1); + dir.normalize(); + game.player.pullDir.copy(dir); + game.player.pullTime = PULL_TIME; + playSound('damage', 0.9); + } + } + } + + private updateSlam(dt: number) { + this.slamTime -= dt; + const s = Math.max(0, this.slamTime / SLAM_TIME); + this.slamChain.scale.set(1, 1, this.slamRetractFrom * s); + + if (this.slamTime <= 0) { + this.slamChain.visible = false; + this.slamState = 'none'; + } + } + + private updateSweep(dt: number, game: Game) { + if (!this.sweepActive) { + this.sweepTimer -= dt; + const cd = this.enraged ? 3.2 : 4.5; + if (this.sweepTimer <= 0) { + this.sweepTimer = cd; + this.sweepActive = true; + this.sweepHit = false; + const start = Math.random() * Math.PI * 2; + for (let i = 0; i < this.sweepTents.length; i++) { + const tent = this.sweepTents[i]; + tent.angle = start + i * Math.PI; + tent.progress = 0; + tent.group.position.set(this.body.position.x, 0.06, this.body.position.z); + // Tentakel liegt auf lokaler +X; rotation.y = -Winkel zeigt auf (cos, sin) + tent.group.rotation.y = -tent.angle; + tent.group.visible = i === 0 || this.enraged; + } + playSound('spawn', 0.6); + } + return; + } + + const rotSpeed = ((Math.PI * 2) / (this.enraged ? 3.0 : SWEEP_DURATION)) * dt; + + for (const tent of this.sweepTents) { + // Phase 2: zweiter Tentakel kreist gegensinnig + if (tent.dir === -1 && !this.enraged) { + tent.group.visible = false; + continue; + } + tent.angle += tent.dir * rotSpeed; + tent.progress += rotSpeed; + tent.group.rotation.y = -tent.angle; + if (!this.sweepHit) { + const playerPos = game.player.body.position; + const dx = playerPos.x - this.body.position.x; + const dz = playerPos.z - this.body.position.z; + const dist = Math.hypot(dx, dz); + if (dist > 0.5 && dist < SWEEP_RADIUS + 0.5) { + const dirX = Math.cos(tent.angle); + const dirZ = Math.sin(tent.angle); + // Abstand des Spielers zur Tentakel-Linie (radial vom Boss) + const perp = Math.abs(dx * dirZ - dz * dirX); + const along = dx * dirX + dz * dirZ; + // Ueberspringbar: nur Treffer, solange der Spieler am Boden ist + if (along > 0.3 && perp < SWEEP_HIT_WIDTH && playerPos.y <= 0.6) { + this.sweepHit = true; + game.player.damage(SWEEP_DAMAGE, this.body.position); + playSound('damage', 0.8); + } + } + } + } + + if (this.sweepTents.every(t => t.progress >= Math.PI * 2)) { + this.sweepActive = false; + for (const tent of this.sweepTents) { + tent.group.visible = false; + } + } + } + + protected onDeath(_game: Game) { + this.cleanupEffects(); + this.beginFadeDeath(); + } + + override dispose() { + super.dispose(); + this.cleanupEffects(); + } + + private cleanupEffects() { + for (const blob of this.inkBlobs) { + blob.dispose(); + } + this.inkBlobs = []; + for (const puddle of this.puddles) { + puddle.dispose(); + } + this.puddles = []; + if (this.game) { + for (const mark of this.pullMarks) { + this.game.scene.remove(mark.group); + } + this.game.scene.remove(this.slamChain); + for (const tent of this.sweepTents) { + this.game.scene.remove(tent.group); + } + } + for (const mark of this.pullMarks) { + for (const child of mark.group.children) { + if (child instanceof THREE.Mesh) { + child.geometry.dispose(); + (child.material as THREE.Material).dispose(); + } + } + } + this.pullMarks = []; + for (const tent of this.sweepTents) { + for (const child of tent.group.children) { + if (child instanceof THREE.Mesh) { + child.geometry.dispose(); + (child.material as THREE.Material).dispose(); + } + } + } + for (const child of this.slamChain.children) { + if (child instanceof THREE.Mesh) { + child.geometry.dispose(); + (child.material as THREE.Material).dispose(); + } + } + } +} diff --git a/web/src/Player.ts b/web/src/Player.ts index 78a9ddf..5bf77e8 100644 --- a/web/src/Player.ts +++ b/web/src/Player.ts @@ -30,6 +30,10 @@ export class Player { onGround = true; shieldHp = 0; shieldTimer = 0; + slowTimer = 0; + slowFactor = 0.6; + pullTime = 0; + pullDir = new THREE.Vector3(); stats: PlayerStats = { maxHealth: 100, regen: 0, @@ -50,6 +54,7 @@ export class Player { private flashTime = 0; private static readonly BASE_COLOR = 0x44aa44; private static readonly HURT_COLOR = 0xff5555; + private static readonly SLOW_COLOR = 0x9944cc; private static readonly HURT_FLASH_TIME = 0.3; constructor() { @@ -217,14 +222,23 @@ export class Player { updateAnimations(dt: number) { if (this.mixer) this.mixer.update(dt); if (this.hurtCooldown > 0) this.hurtCooldown -= dt; + if (this.slowTimer > 0) this.slowTimer -= dt; + + let color: THREE.Color; if (this.flashTime > 0) { this.flashTime -= dt; const t = Math.max(0, this.flashTime) / Player.HURT_FLASH_TIME; - const color = new THREE.Color(Player.BASE_COLOR).lerp( + color = new THREE.Color(Player.BASE_COLOR).lerp( new THREE.Color(Player.HURT_COLOR), t ); - for (const mat of this.bodyMaterials) mat.color.copy(color); + } else if (this.slowTimer > 0) { + color = new THREE.Color(Player.BASE_COLOR).lerp( + new THREE.Color(Player.SLOW_COLOR), 0.55 + ); + } else { + color = new THREE.Color(Player.BASE_COLOR); } + for (const mat of this.bodyMaterials) mat.color.copy(color); } setGame(game: Game) { @@ -316,6 +330,8 @@ export class Player { this.health = 100; this.shieldHp = 0; this.shieldTimer = 0; + this.slowTimer = 0; + this.pullTime = 0; this.body.position.set(0, 0.5, 0); this.velocity.set(0, 0, 0); this.jumpVelocity = 0; diff --git a/web/src/Skills.ts b/web/src/Skills.ts index 49eeb0f..6fd5b44 100644 --- a/web/src/Skills.ts +++ b/web/src/Skills.ts @@ -233,15 +233,16 @@ export class SkillSystem { return this.xp / this.xpNeeded(); } - addXp(amount: number): boolean { + addXp(amount: number): number { this.xp += amount; - if (!this.hasAnyUpgradeLeft()) return false; - if (this.xp >= this.xpNeeded()) { + if (!this.hasAnyUpgradeLeft()) return 0; + let leveled = 0; + while (this.xp >= this.xpNeeded()) { this.xp -= this.xpNeeded(); this.level++; - return true; + leveled++; } - return false; + return leveled; } hasPendingLevelUp(): boolean { diff --git a/web/src/Turret.ts b/web/src/Turret.ts index 445e64a..4974007 100644 --- a/web/src/Turret.ts +++ b/web/src/Turret.ts @@ -17,6 +17,7 @@ export class Turret extends Enemy { this.speed = 0; this.xp = 25; this.contactDps = 0; + this.immovable = true; this.model = new THREE.Group(); this.fadeOutModel = this.model;