import * as THREE from 'three'; import { Player } from './Player'; import { Enemy } from './Enemy'; import { Spider, initSpiderModel } from './Spider'; import { Ghost } from './Ghost'; import { Frog } from './Frog'; import { Bat } from './Bat'; import { BeeSwarm } from './BeeSwarm'; import { Slime } from './Slime'; import { Brute } from './Brute'; import { Golem } from './Golem'; import { Krake } from './Krake'; import { Minotaurus } from './Minotaurus'; import { SpiderQueen } from './SpiderQueen'; import { FrostGiant } from './FrostGiant'; import { Necromancer } from './Necromancer'; import { Dragon } from './Dragon'; import { Maze } from './Maze'; import { MazeGuard } from './MazeGuard'; import { Gnome } from './Gnome'; 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'; import { Fireball } from './Fireball'; import { Cross, initCrossModel } from './Cross'; import { Arena } from './Arena'; import { SlashEffect, isInSlashArc } from './SwordTrail'; import { SkillSystem, getSkill, HOTBAR_SKILLS, type SkillOffer } from './Skills'; import { skillIcon } from './SkillIcons'; import { initSounds, playSound, resumeContext } from './SoundManager'; import { Aura } from './Aura'; import { Boomerang } from './Boomerang'; import { Trap } from './Trap'; import { GasCloud } from './GasCloud'; import { Trapper } from './Trapper'; import { DamageNumber } from './DamageNumber'; const SPAWN_TIME = 10; const HOTBAR_KEY_CODES = ['KeyQ', 'KeyE', 'Digit1', 'Digit2', 'Digit3', 'Digit4']; const HOTBAR_KEY_LABELS = ['Q', 'E', '1', '2', '3', '4']; type GameState = 'menu' | 'playing' | 'gameover' | 'levelup'; export class Game { scene: THREE.Scene; camera: THREE.PerspectiveCamera; renderer: THREE.WebGLRenderer; clock: THREE.Clock; private playerLight!: THREE.PointLight; player!: Player; enemies: Enemy[] = []; fireballs: Fireball[] = []; crosses: Cross[] = []; slashEffects: SlashEffect[] = []; projectiles: EnemyProjectile[] = []; traps: Trap[] = []; gasClouds: GasCloud[] = []; private damageNumbers: DamageNumber[] = []; private aura: Aura | null = null; private boomerang: Boomerang | null = null; private trapper = new Trapper(); private hotbar: (string | null)[] = Array(HOTBAR_KEY_CODES.length).fill(null); state: GameState = 'menu'; private paused = false; skillSystem: SkillSystem = new SkillSystem(0); private seed: number | null = null; private offers: SkillOffer[] = []; private selectedOffer = -1; private pendingPicks = 0; private gameOverAt = 0; keys: Set = new Set(); mouseButtons: Set = new Set(); private prevMouseButtons: Set = new Set(); mouseX = 0; mouseY = 0; groundPoint = new THREE.Vector3(); spawnTimer = 0; 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; // Arena-Theme: eine Stufe pro besiegten Boss private arena!: Arena; private arenaStage = 0; private themeFogTarget = new THREE.Color(0x0d1117); private themeLightTarget = new THREE.Color(0xffeebb); private static readonly THEME_LIGHTS = [ 0xffeebb, 0xffddaa, 0x99ccff, 0xffbb99, 0xbbffcc, 0xbbddff, 0xddbbff, 0xffcc88, ]; // Labyrinth-Zwischenboss (Welle 5, 15, 25, ...): ersetzt die Welle komplett maze: Maze | null = null; private mazeActive = false; private mazeTimer = 0; private mazeTotal = 1; private mazeLevel = 0; private mazeGoalsLeft = 0; private mazeGnome: Gnome | null = null; private mazeGuards: MazeGuard[] = []; private uiMazeArrow!: HTMLElement; private static readonly MAZE_WAVES = [5, 15, 25, 35, 45, 55, 65]; private static readonly MAZE_BASE_TIME = 75; private static readonly MAZE_TIME_STEP = 5; private static readonly MAZE_MIN_TIME = 50; // 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() }], [30, { name: 'Minotaurus', spawn: () => new Minotaurus() }], [40, { name: 'Spinnenkönigin', spawn: () => new SpiderQueen() }], [50, { name: 'Frostriese', spawn: () => new FrostGiant() }], [60, { name: 'Erz-Nekromant', spawn: () => new Necromancer() }], [70, { name: 'Drache', spawn: () => new Dragon() }], ]); private bossFightActive = false; private shakeTime = 0; private shakeAmp = 0; // Debug hitzone visualization private showHitzones = false; private playerHitRing!: THREE.Mesh; private enemyHitRings: Map = new Map(); private fireballHitRings: Map = new Map(); private crossHitRings: Map = new Map(); // UI elements private uiHealthBar!: HTMLElement; private uiHealthText!: HTMLElement; private uiWaveLabel!: HTMLElement; private uiKillsLabel!: HTMLElement; private uiFireballCD!: HTMLElement; private uiFireballContainer!: HTMLElement; private uiSwordCD!: HTMLElement; private uiTeleportCD!: HTMLElement; private uiTeleportContainer!: HTMLElement; private uiLightningCD!: HTMLElement; private uiLightningContainer!: HTMLElement; private uiWaveBarFill!: HTMLElement; private uiWaveBarLabel!: HTMLElement; private uiGameOver!: HTMLElement; private uiGameOverPanel!: HTMLElement; private uiGameOverStats!: HTMLElement; private uiMainMenu!: HTMLElement; private uiSeedInput!: HTMLInputElement; private uiBossMode!: HTMLElement; private uiBossModeButtons!: HTMLElement; private uiChangelogPanel!: HTMLElement; private cheatBuffer = ''; private uiLevelLabel!: HTMLElement; private uiSeedLabel!: HTMLElement; private uiXpBarFill!: HTMLElement; private uiXpBarLabel!: HTMLElement; private uiShieldBar!: HTMLElement; private uiShieldBarBg!: HTMLElement; private uiShieldText!: HTMLElement; private uiLevelUp!: HTMLElement; private uiConfirmBtn!: HTMLButtonElement; private uiOfferIcons: HTMLElement[] = []; private uiOfferTitles: HTMLElement[] = []; 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; private uiGasCD!: HTMLElement; private uiLightningLabel!: HTMLElement; private uiTeleportLabel!: HTMLElement; private uiTrapLabel!: HTMLElement; private uiGasLabel!: HTMLElement; private uiPauseOverlay!: HTMLElement; private mouseOnCanvas = false; private bloodParticles: { mesh: THREE.Mesh; vel: THREE.Vector3; life: number; mat: THREE.MeshBasicMaterial; }[] = []; constructor() { this.scene = new THREE.Scene(); this.scene.background = new THREE.Color(0x0d1117); this.scene.fog = new THREE.Fog(0x0d1117, 25, 70); this.camera = new THREE.PerspectiveCamera( 60, window.innerWidth / window.innerHeight, 1, 150 ); this.camera.position.set(0, 25, -15); this.camera.lookAt(0, 0, 0); this.renderer = new THREE.WebGLRenderer({ antialias: true }); this.renderer.setSize(window.innerWidth, window.innerHeight); this.renderer.shadowMap.enabled = true; this.renderer.shadowMap.type = THREE.PCFSoftShadowMap; this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); document.body.prepend(this.renderer.domElement); this.clock = new THREE.Clock(); this.setupUI(); this.setupInput(); this.setupLighting(); } async init() { this.setupArena(); this.setupPlayer(); this.player.setGame(this); initSounds(); await Promise.all([ this.player.init(), this.player.rightHandWeapon.init(), this.player.leftHandWeapon.init(), initSpiderModel(), initCrossModel(), ]); } start() { window.addEventListener('resize', () => { this.camera.aspect = window.innerWidth / window.innerHeight; this.camera.updateProjectionMatrix(); this.renderer.setSize(window.innerWidth, window.innerHeight); }); this.animate(); } private setupUI() { this.uiHealthBar = document.getElementById('health-bar')!; this.uiHealthText = document.getElementById('health-text')!; this.uiWaveLabel = document.getElementById('wave-label')!; this.uiKillsLabel = document.getElementById('kills-label')!; this.uiFireballCD = document.querySelector('#fireball-cooldown .cooldown-fill')!; this.uiFireballContainer = document.getElementById('fireball-cooldown')!; this.uiSwordCD = document.querySelector('#sword-cooldown .cooldown-fill')!; this.uiTeleportCD = document.querySelector('#teleport-cooldown .cooldown-fill')!; this.uiTeleportContainer = document.getElementById('teleport-cooldown')!; this.uiLightningCD = document.querySelector('#lightning-cooldown .cooldown-fill')!; this.uiLightningContainer = document.getElementById('lightning-cooldown')!; this.uiWaveBarFill = document.getElementById('wave-bar-fill')!; this.uiWaveBarLabel = document.getElementById('wave-bar-label')!; this.uiMazeArrow = document.getElementById('maze-arrow')!; this.uiGameOver = document.getElementById('game-over')!; this.uiGameOverPanel = document.getElementById('game-over-panel')!; this.uiGameOverStats = document.getElementById('game-over-stats')!; this.uiMainMenu = document.getElementById('main-menu')!; this.uiSeedInput = document.getElementById('seed-input') as HTMLInputElement; this.uiLevelLabel = document.getElementById('level-label')!; this.uiSeedLabel = document.getElementById('seed-label')!; this.uiXpBarFill = document.getElementById('xp-bar-fill')!; this.uiXpBarLabel = document.getElementById('xp-bar-label')!; this.uiShieldBar = document.getElementById('shield-bar')!; this.uiShieldBarBg = document.getElementById('shield-bar-bg')!; this.uiShieldText = document.getElementById('shield-text')!; this.uiLevelUp = document.getElementById('level-up')!; this.uiConfirmBtn = document.getElementById('btn-confirm') as HTMLButtonElement; 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')!; this.uiGasCD = document.querySelector('#gas-cooldown .cooldown-fill')!; this.uiLightningLabel = document.querySelector('#lightning-cooldown .cooldown-label')!; this.uiTeleportLabel = document.querySelector('#teleport-cooldown .cooldown-label')!; this.uiTrapLabel = document.querySelector('#trap-cooldown .cooldown-label')!; this.uiGasLabel = document.querySelector('#gas-cooldown .cooldown-label')!; for (let i = 0; i < 3; i++) { const card = document.getElementById(`offer-${i}`)!; card.addEventListener('click', () => this.selectOffer(i)); this.uiOfferIcons.push(document.getElementById(`offer-icon-${i}`)!); this.uiOfferTitles.push(document.getElementById(`offer-title-${i}`)!); this.uiOfferDescs.push(document.getElementById(`offer-desc-${i}`)!); } document.getElementById('btn-start')!.addEventListener('click', () => this.startGame()); document.getElementById('btn-changelog')!.addEventListener('click', () => this.showChangelog()); document.getElementById('btn-changelog-close')!.addEventListener('click', () => this.hideChangelog()); this.uiChangelogPanel = document.getElementById('changelog-panel')!; document.getElementById('btn-restart')!.addEventListener('click', () => this.restart()); document.getElementById('btn-menu')!.addEventListener('click', () => this.showMenu()); this.uiPauseOverlay = document.getElementById('pause-overlay')!; document.getElementById('btn-resume')!.addEventListener('click', () => this.resume()); document.getElementById('btn-pause-menu')!.addEventListener('click', () => this.showMenu()); // Diagnostics: detect a full page reload that wiped a running game if (sessionStorage.getItem('wackelpeter-run') === '1') { console.warn('[Wackelpeter] Seite wurde während eines Laufs neu geladen!'); sessionStorage.removeItem('wackelpeter-run'); } window.addEventListener('beforeunload', () => { console.log('[Wackelpeter] beforeunload (Seite wird neu geladen/geschlossen)'); }); const canvas = this.renderer.domElement; canvas.addEventListener('mouseenter', () => this.mouseOnCanvas = true); canvas.addEventListener('mouseleave', () => this.mouseOnCanvas = false); window.addEventListener('contextmenu', e => e.preventDefault()); } private logState(next: GameState, reason: string) { console.log(`[Wackelpeter] state: ${this.state} → ${next} (${reason})`); } private setupInput() { window.addEventListener('keydown', (e) => { this.keys.add(e.code); if (this.paused) { if (e.code === 'Escape') this.resume(); e.preventDefault(); return; } if (e.code === 'Escape') { if (this.state === 'playing') { this.pause(); } else if (this.state === 'gameover') { this.showMenu(); } else if (this.state === 'menu' && this.uiChangelogPanel.style.visibility === 'visible') { this.hideChangelog(); } e.preventDefault(); return; } if (e.code === 'Space' && this.state === 'playing' && this.player.pullTime <= 0 && this.player.stunTime <= 0 && this.player.rootTime <= 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]!); } if (this.state === 'levelup') { if (e.code === 'Digit1' || e.code === 'Numpad1') this.selectOffer(0); else if (e.code === 'Digit2' || e.code === 'Numpad2') this.selectOffer(1); else if (e.code === 'Digit3' || e.code === 'Numpad3') this.selectOffer(2); else if (e.code === 'Enter') this.confirmOffer(); e.preventDefault(); return; } if (e.code === 'Enter' || e.code === 'Space') { if (this.state === 'menu' && this.uiChangelogPanel.style.visibility !== 'visible') { this.startGame(); } else if ( this.state === 'gameover' && performance.now() - this.gameOverAt > 1500 ) { this.restart(); } } }); window.addEventListener('keyup', (e) => { this.keys.delete(e.code); }); window.addEventListener('mousedown', (e) => { resumeContext(); this.mouseButtons.add(e.button); this.prevMouseButtons.add(e.button); }); window.addEventListener('mouseup', (e) => { resumeContext(); this.mouseButtons.delete(e.button); this.handleMouseRelease(e.button); }); window.addEventListener('mousemove', (e) => { this.mouseX = (e.clientX / window.innerWidth) * 2 - 1; this.mouseY = -(e.clientY / window.innerHeight) * 2 + 1; }); } private handleMouseRelease(button: number) { if (this.state !== 'playing' || this.paused) return; if (button === 0) { this.player.rightHandWeapon.skill1(this.groundPoint, this.player); } else if (button === 2) { this.player.leftHandWeapon.skill1(this.groundPoint, this.player); } } private triggerHotbarSkill(id: string) { switch (id) { case 'chainlightning': this.player.skillLightning.skill1(this.groundPoint, this.player); break; case 'teleport': this.player.leftHandWeapon.skill2(this.groundPoint, this.player); break; case 'trap': this.trapper.skill1(this.groundPoint, this.player); break; case 'gascloud': this.trapper.skill2(this.groundPoint, this.player); break; } } private hotbarKeyLabel(skillId: string): string { const idx = this.hotbar.indexOf(skillId); return idx >= 0 ? HOTBAR_KEY_LABELS[idx] : ''; } private nextFreeHotbarLabel(): string { const idx = this.hotbar.indexOf(null); return idx >= 0 ? HOTBAR_KEY_LABELS[idx] : ''; } private setupLighting() { const ambient = new THREE.AmbientLight(0x556688, 0.08); this.scene.add(ambient); const dirLight = new THREE.DirectionalLight(0xccddff, 0.15); dirLight.position.set(30, 40, 20); dirLight.castShadow = true; dirLight.shadow.mapSize.width = 1024; dirLight.shadow.mapSize.height = 1024; dirLight.shadow.camera.near = 1; dirLight.shadow.camera.far = 150; dirLight.shadow.camera.left = -60; dirLight.shadow.camera.right = 60; dirLight.shadow.camera.top = 60; dirLight.shadow.camera.bottom = -60; this.scene.add(dirLight); // Lichtschein um den Spieler: Radius erhellt Umgebung, außerhalb wird es dunkler this.playerLight = new THREE.PointLight(0xffeebb, 90, 20, 2); this.playerLight.position.set(0, 6, 0); this.scene.add(this.playerLight); } private setupArena() { this.arena = new Arena(); this.scene.add(this.arena); } private setupPlayer() { this.player = new Player(); this.player.body.position.set(0, 0.5, 0); this.scene.add(this.player.body); // Player hitzone ring (contact damage radius = 2) this.playerHitRing = this.createHitRing(2, 0xff0000); this.scene.add(this.playerHitRing); } private createHitRing(radius: number, color: number): THREE.Mesh { const geom = new THREE.RingGeometry(radius - 0.05, radius, 48); const mat = new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.5, side: THREE.DoubleSide, depthWrite: false, }); const ring = new THREE.Mesh(geom, mat); ring.rotation.x = -Math.PI / 2; ring.position.y = 0.05; ring.renderOrder = 999; return ring; } private updateHitzones() { this.playerHitRing.position.x = this.player.body.position.x; this.playerHitRing.position.z = this.player.body.position.z; this.playerHitRing.visible = this.showHitzones; // Enemy rings for (const enemy of this.enemies) { let ring = this.enemyHitRings.get(enemy); if (!ring) { ring = this.createHitRing(2, 0xff8800); this.scene.add(ring); this.enemyHitRings.set(enemy, ring); } ring.position.x = enemy.body.position.x; ring.position.z = enemy.body.position.z; ring.visible = this.showHitzones && !enemy.dead; } // Clean up removed enemies for (const [enemy, ring] of this.enemyHitRings) { if (!this.enemies.includes(enemy)) { this.scene.remove(ring); ring.geometry.dispose(); (ring.material as THREE.Material).dispose(); this.enemyHitRings.delete(enemy); } } // Fireball rings (impact radius 1.5 / AoE 5) for (const fb of this.fireballs) { let ring = this.fireballHitRings.get(fb); if (!ring) { ring = this.createHitRing(fb.exploding ? 5 : 1.5, fb.exploding ? 0xffff00 : 0xff6600); this.scene.add(ring); this.fireballHitRings.set(fb, ring); } ring.position.x = fb.body.position.x; ring.position.z = fb.body.position.z; ring.visible = this.showHitzones; } for (const [fb, ring] of this.fireballHitRings) { if (!this.fireballs.includes(fb)) { this.scene.remove(ring); ring.geometry.dispose(); (ring.material as THREE.Material).dispose(); this.fireballHitRings.delete(fb); } } // Cross rings (pickup radius 2) for (const cross of this.crosses) { let ring = this.crossHitRings.get(cross); if (!ring) { ring = this.createHitRing(2, 0x00ff00); this.scene.add(ring); this.crossHitRings.set(cross, ring); } ring.position.x = cross.body.position.x; ring.position.z = cross.body.position.z; ring.visible = this.showHitzones; } for (const [cross, ring] of this.crossHitRings) { if (!this.crosses.includes(cross)) { this.scene.remove(ring); ring.geometry.dispose(); (ring.material as THREE.Material).dispose(); this.crossHitRings.delete(cross); } } } startGame() { if (this.state === 'playing' || this.state === 'levelup') return; if (this.seed === null) { const input = this.uiSeedInput.value.trim(); if (input !== '' && !Number.isNaN(Number(input))) { this.seed = Number(input) >>> 0; } else { this.seed = Math.floor(Math.random() * 1000000); } } this.resetWorld(); this.beginRun(); } private beginRun() { this.logState('playing', 'beginRun'); this.state = 'playing'; this.paused = false; this.hidePauseOverlay(); sessionStorage.setItem('wackelpeter-run', '1'); this.uiMainMenu.style.opacity = '0'; this.uiMainMenu.style.pointerEvents = 'none'; this.uiMainMenu.style.visibility = 'hidden'; this.hideChangelog(); this.uiGameOver.style.opacity = '0'; this.uiGameOverPanel.style.transition = 'opacity 0.3s ease'; this.uiGameOverPanel.style.opacity = '0'; this.uiGameOverPanel.style.pointerEvents = 'none'; this.uiGameOverPanel.style.visibility = 'hidden'; 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); } for (const wave of Game.MAZE_WAVES) { const btn = document.createElement('button'); btn.className = 'menu-button'; btn.textContent = `Labyrinth (Welle ${wave})`; btn.style.fontSize = '14px'; btn.style.padding = '8px 20px'; btn.addEventListener('click', () => this.startMazeFight(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); } private showChangelog() { this.uiChangelogPanel.style.opacity = '1'; this.uiChangelogPanel.style.pointerEvents = 'auto'; this.uiChangelogPanel.style.visibility = 'visible'; this.blurFocus(); } private hideChangelog() { this.uiChangelogPanel.style.opacity = '0'; this.uiChangelogPanel.style.pointerEvents = 'none'; this.uiChangelogPanel.style.visibility = 'hidden'; this.blurFocus(); } 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(); } startMazeFight(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, 0.25); this.startMazeWave(); this.beginRun(); } private grantRandomSkills(wave: number, factor = 0.4) { const picks = Math.round(wave * factor); 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(); } // --- Labyrinth-Zwischenboss --- private isMazeWave(wave: number): boolean { return Game.MAZE_WAVES.includes(wave); } private startMazeWave() { this.disposeMaze(); this.maze = new Maze(this.scene); this.mazeActive = true; this.mazeLevel = Math.max(0, Math.floor((this.spawns - 5) / 10)); // Spieler zur Startecke const start = this.maze.startPosition(); this.player.body.position.set(start.x, 0.5, start.z); this.player.velocity.set(0, 0, 0); // Ab Level 2 (Welle 25) muss der Gnom 2x gefunden werden this.mazeGoalsLeft = this.mazeLevel >= 2 ? 2 : 1; this.mazeTimer = Math.max( Game.MAZE_MIN_TIME, Game.MAZE_BASE_TIME - Game.MAZE_TIME_STEP * this.mazeLevel ); this.mazeTotal = this.mazeTimer; this.spawnMazeGnome(); this.spawnMazeGuards(); this.updateMazeArrow(); playSound('spawn', 0.8); } private spawnMazeGnome() { if (!this.maze) return; const gnome = new Gnome(150 + 100 * this.mazeLevel, 60 + 30 * this.mazeLevel); const goal = this.maze.goalPosition(this.mazeGoalsLeft - 1); this.mazeGnome = gnome; this.spawnEnemyInstance(gnome, goal.x, goal.z, 0.1); } private spawnMazeGuards() { if (!this.maze) return; const count = this.mazeLevel >= 2 ? 4 : this.mazeLevel >= 1 ? 2 : 0; if (count <= 0) return; const spots = this.maze.guardPositions(count); for (const pos of spots) { const guard = new MazeGuard(); this.mazeGuards.push(guard); this.spawnEnemyInstance(guard, pos.x, pos.z, 1.0); } } private updateMaze(dt: number) { if (!this.mazeActive || !this.maze) return; this.mazeTimer -= dt; this.updateMazeArrow(); if (this.mazeTimer <= 0) { // Fehlschlag: neues Labyrinth, zurück zur Startecke playSound('explosion', 0.5); this.spawnDamageText('ZEIT ABGELAUFEN!', this.player.body.position, '#ff6644'); this.triggerCameraShake(0.3, 0.3); this.startMazeWave(); return; } // Gnom besiegt? if (this.mazeGnome && this.mazeGnome.dead) { this.mazeGoalsLeft--; this.mazeGnome = null; if (this.mazeGoalsLeft > 0) { this.spawnMazeGnome(); } else { this.endMazeWave(true); } } } private endMazeWave(success: boolean) { this.mazeActive = false; this.disposeMaze(); if (success) { playSound('explosion', 0.7); this.triggerCameraShake(0.3, 0.3); } this.spawnTimer = 0; } private disposeMaze() { if (this.maze) { this.maze.dispose(); this.maze = null; } this.mazeActive = false; // Lebenden Gnom (Fehlschlag/Neustart) entfernen; tote bleiben als Leichen if (this.mazeGnome && !this.mazeGnome.dead) { this.mazeGnome.dispose(); this.scene.remove(this.mazeGnome.body); const idx = this.enemies.indexOf(this.mazeGnome); if (idx >= 0) this.enemies.splice(idx, 1); } this.mazeGnome = null; for (const guard of this.mazeGuards) { if (!guard.dead) { guard.dispose(); this.scene.remove(guard.body); const idx = this.enemies.indexOf(guard); if (idx >= 0) this.enemies.splice(idx, 1); } } this.mazeGuards = []; this.hideMazeArrow(); } // Pfeil am Bildschirmrand zeigt zum aktuellen Ziel-Gnom private updateMazeArrow() { const el = this.uiMazeArrow; if (!this.mazeActive || !this.mazeGnome || !this.maze) { this.hideMazeArrow(); return; } const goal = this.mazeGnome.body.position; const v = goal.clone().project(this.camera); const onScreen = v.x > -1 && v.x < 1 && v.y > -1 && v.y < 1 && v.z < 1; if (onScreen) { this.hideMazeArrow(); return; } const clx = THREE.MathUtils.clamp(v.x, -0.8, 0.8); const cly = THREE.MathUtils.clamp(v.y, -0.8, 0.8); const cx = window.innerWidth / 2; const cy = window.innerHeight / 2; // NDC-y zeigt nach oben, Bildschirm-y nach unten -> y flippen const ax = clx * cx; const ay = -cly * cy; const gx = v.x * cx; const gy = -v.y * cy; el.style.left = `${cx + ax}px`; el.style.top = `${cy + ay}px`; el.style.transform = `translate(-50%, -50%) rotate(${Math.atan2(gy - ay, gx - ax)}rad)`; el.style.opacity = '1'; el.style.visibility = 'visible'; el.style.pointerEvents = 'none'; } private hideMazeArrow() { const el = this.uiMazeArrow; el.style.opacity = '0'; el.style.visibility = 'hidden'; el.style.pointerEvents = 'none'; } private blurFocus() { const el = document.activeElement; if (el instanceof HTMLElement) el.blur(); } restart() { this.startGame(); } private pause() { if (this.state !== 'playing' || this.paused) return; this.paused = true; this.uiPauseOverlay.style.visibility = 'visible'; this.uiPauseOverlay.style.opacity = '1'; this.uiPauseOverlay.style.pointerEvents = 'auto'; this.blurFocus(); } private resume() { if (!this.paused) return; this.paused = false; this.hidePauseOverlay(); this.blurFocus(); } private hidePauseOverlay() { this.uiPauseOverlay.style.opacity = '0'; this.uiPauseOverlay.style.pointerEvents = 'none'; this.uiPauseOverlay.style.visibility = 'hidden'; } showMenu() { if ((this.state === 'playing' || this.state === 'levelup') && !this.paused) { return; } this.paused = false; this.hidePauseOverlay(); this.resetWorld(); this.logState('menu', 'showMenu'); this.state = 'menu'; this.seed = null; sessionStorage.removeItem('wackelpeter-run'); this.uiGameOver.style.opacity = '0'; this.uiGameOverPanel.style.transition = 'opacity 0.3s ease'; this.uiGameOverPanel.style.opacity = '0'; this.uiGameOverPanel.style.pointerEvents = 'none'; this.uiGameOverPanel.style.visibility = 'hidden'; this.uiMainMenu.style.opacity = '1'; this.uiMainMenu.style.pointerEvents = 'auto'; this.uiMainMenu.style.visibility = 'visible'; this.blurFocus(); } private gameOver() { this.logState('gameover', 'gameOver'); this.state = 'gameover'; this.gameOverAt = performance.now(); this.player.die(); this.blurFocus(); this.uiGameOver.style.opacity = '1'; this.uiGameOverStats.textContent = `Wave ${this.spawns} · Kills: ${this.killed} · Level ${this.skillSystem.level}`; this.uiGameOverPanel.style.transition = 'opacity 0.8s ease-in 1.5s'; this.uiGameOverPanel.style.opacity = '1'; this.uiGameOverPanel.style.pointerEvents = 'auto'; this.uiGameOverPanel.style.visibility = 'visible'; playSound('gameover', 0.8); } private resetWorld() { for (const enemy of this.enemies) { enemy.dispose(); this.scene.remove(enemy.body); } this.enemies = []; for (const fb of this.fireballs) this.scene.remove(fb.body); this.fireballs = []; for (const cross of this.crosses) this.scene.remove(cross.body); this.crosses = []; for (const slash of this.slashEffects) slash.dispose(); this.slashEffects = []; for (const p of this.projectiles) p.dispose(); this.projectiles = []; for (const trap of this.traps) trap.dispose(); this.traps = []; for (const cloud of this.gasClouds) cloud.dispose(); this.gasClouds = []; for (const num of this.damageNumbers) num.dispose(this.scene); this.damageNumbers = []; if (this.aura) { this.aura.dispose(this.scene); this.aura = null; } if (this.boomerang) { this.boomerang.dispose(this.scene); this.boomerang = null; } this.hotbar = Array(HOTBAR_KEY_CODES.length).fill(null); this.trapper.cooldown1 = 0; this.trapper.cooldown2 = 0; this.trapper.cooldown3 = 0; for (const [enemy, ring] of this.enemyHitRings) { this.scene.remove(ring); ring.geometry.dispose(); (ring.material as THREE.Material).dispose(); this.enemyHitRings.delete(enemy); } for (const [fb, ring] of this.fireballHitRings) { this.scene.remove(ring); ring.geometry.dispose(); (ring.material as THREE.Material).dispose(); this.fireballHitRings.delete(fb); } for (const [cross, ring] of this.crossHitRings) { this.scene.remove(ring); ring.geometry.dispose(); (ring.material as THREE.Material).dispose(); this.crossHitRings.delete(cross); } this.spawnTimer = 0; this.spawns = 0; this.killed = 0; this.bossFightActive = false; this.offscreenTimers.clear(); // Arena-Theme zurücksetzen this.arenaStage = 0; this.themeFogTarget.setHex(0x0d1117); this.themeLightTarget.setHex(0xffeebb); this.arena.applyTheme(0); (this.scene.fog as THREE.Fog).color.copy(this.themeFogTarget); (this.scene.background as THREE.Color).copy(this.themeFogTarget); this.playerLight.color.copy(this.themeLightTarget); // Labyrinth aufräumen this.disposeMaze(); this.shakeTime = 0; this.pendingPicks = 0; this.hideLevelUp(); this.skillSystem = new SkillSystem(this.seed ?? 0); this.player.reset(); this.player.applySkillSystem(this.skillSystem); this.syncSkillVisibility(); this.player.rightHandWeapon.cooldown1 = 0; this.player.rightHandWeapon.cooldown2 = 0; this.player.rightHandWeapon.cooldown3 = 0; this.player.leftHandWeapon.cooldown1 = 0; this.player.leftHandWeapon.cooldown2 = 0; this.player.leftHandWeapon.cooldown3 = 0; this.player.skillLightning.cooldown1 = 0; this.player.skillLightning.cooldown2 = 0; this.player.skillLightning.cooldown3 = 0; this.updateUI(); } onDamageDealt(damage: number, target?: Enemy) { if (this.state === 'playing' && this.player.stats.lifesteal > 0) { this.player.heal(damage * this.player.stats.lifesteal); } // Treffer-Feedback: Schadenszahl bei Bossen einblenden if (target?.isBoss && !target.dead) { this.spawnDamageText(`${Math.round(damage)}`, target.body.position); } } 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) { // Everything maxed out: no picks left, just continue playing this.pendingPicks = 0; this.hideLevelUp(); this.logState('playing', 'openLevelUp (keine Angebote)'); this.state = 'playing'; return; } this.logState('levelup', 'openLevelUp'); this.state = 'levelup'; this.selectedOffer = -1; this.blurFocus(); playSound('spawn', 0.6); for (let i = 0; i < 3; i++) { const title = this.uiOfferTitles[i]; const desc = this.uiOfferDescs[i]; const card = document.getElementById(`offer-${i}`)!; if (i < this.offers.length) { const offer = this.offers[i]; const skill = getSkill(offer.id); const isNew = offer.nextLevel === 1; this.uiOfferIcons[i].innerHTML = skillIcon(offer.id); title.textContent = `${skill.name}${isNew ? ' (NEU)' : ` · Stufe ${offer.nextLevel}`}`; let descText = skill.describe(offer.nextLevel); if (isNew && HOTBAR_SKILLS.includes(offer.id)) { const key = this.nextFreeHotbarLabel(); if (key) descText += ` · Taste [${key}]`; } desc.textContent = descText; card.style.display = ''; card.classList.remove('selected'); } else { card.style.display = 'none'; } } this.uiConfirmBtn.disabled = true; this.uiLevelUp.style.visibility = 'visible'; this.uiLevelUp.style.opacity = '1'; this.uiLevelUp.style.pointerEvents = 'auto'; } private hideLevelUp() { this.uiLevelUp.style.opacity = '0'; this.uiLevelUp.style.pointerEvents = 'none'; this.uiLevelUp.style.visibility = 'hidden'; } private selectOffer(index: number) { if (this.state !== 'levelup' || index >= this.offers.length) return; this.selectedOffer = index; for (let i = 0; i < 3; i++) { const card = document.getElementById(`offer-${i}`)!; card.classList.toggle('selected', i === index); } 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); this.assignHotbarSkill(offer.id); this.player.applySkillSystem(this.skillSystem); this.syncSkillVisibility(); this.blurFocus(); // Genau 1 Skill-Punkt pro Level: Pick verbrauchen und nur weiter aufmachen, // wenn noch Picks übrig sind (Multi-Level durch XP-Überschuss) oder // Rest-XP bereits die nächste Stufe erreicht hat. this.pendingPicks = Math.max(0, this.pendingPicks - 1); if (this.pendingPicks > 0 || this.skillSystem.hasPendingLevelUp()) { this.openLevelUp(); } else { this.hideLevelUp(); this.logState('playing', 'confirmOffer'); this.state = 'playing'; } } private syncSkillVisibility() { const s = this.skillSystem; this.player.leftHandWeapon.mesh.visible = s.getLevel('fireball') > 0; this.uiFireballContainer.style.display = s.getLevel('fireball') > 0 ? '' : 'none'; this.uiTeleportContainer.style.display = s.getLevel('teleport') > 0 ? '' : 'none'; this.uiLightningContainer.style.display = s.getLevel('chainlightning') > 0 ? '' : 'none'; this.uiTrapContainer.style.display = s.getLevel('trap') > 0 ? '' : 'none'; this.uiGasContainer.style.display = s.getLevel('gascloud') > 0 ? '' : 'none'; const auraLevel = s.getLevel('aura'); if (auraLevel > 0 && !this.aura) { this.aura = new Aura(this.scene); } else if (auraLevel <= 0 && this.aura) { this.aura.dispose(this.scene); this.aura = null; } const boomerangLevel = s.getLevel('boomerang'); if (boomerangLevel > 0 && !this.boomerang) { this.boomerang = new Boomerang(this.scene); } else if (boomerangLevel <= 0 && this.boomerang) { this.boomerang.dispose(this.scene); this.boomerang = null; } if (this.boomerang) this.boomerang.setLevel(boomerangLevel); this.uiLightningLabel.textContent = `${getSkill('chainlightning').name} [${this.hotbarKeyLabel('chainlightning')}]`; this.uiTeleportLabel.textContent = `${getSkill('teleport').name} [${this.hotbarKeyLabel('teleport')}]`; this.uiTrapLabel.textContent = `${getSkill('trap').name} [${this.hotbarKeyLabel('trap')}]`; this.uiGasLabel.textContent = `${getSkill('gascloud').name} [${this.hotbarKeyLabel('gascloud')}]`; } private spawnEnemy() { // 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; for (const w of weights.values()) total += w; let roll = Math.random() * total; let kind = 'spider'; for (const [k, w] of weights) { roll -= w; if (roll <= 0) { kind = k; break; } } switch (kind) { case 'spider': this.spawnEnemyInstance(new Spider(), x, z, 0.1); break; case 'ghost': this.spawnEnemyInstance(new Ghost(), x, z, 1.0); break; case 'frog': this.spawnEnemyInstance(new Frog(), x, z, 0.1); break; case 'bat': this.spawnEnemyInstance(new Bat(), x, z, 1.5); break; case 'bee': this.spawnEnemyInstance(new BeeSwarm(), x, z, 1.2); break; case 'slime': this.spawnEnemyInstance(new Slime(), x, z, 0.1); break; 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(); // Neue Gegner erscheinen NUR nach den Boss-Wellen (10/20/30/40/50/60/70). // Vor dem ersten Boss (1-10) gibt es nur Spinnen und Frösche. if (wave >= 71) { w.set('spider', 15).set('frog', 10).set('ghost', 10).set('slime', 8) .set('bat', 7).set('archer', 6).set('crab', 7).set('turret', 7) .set('bomber', 6).set('bee', 5).set('brute', 4).set('shaman', 5) .set('spore', 5).set('wisp', 4).set('turtle', 4); } else if (wave >= 61) { w.set('spider', 16).set('frog', 11).set('ghost', 11).set('slime', 8) .set('bat', 8).set('archer', 7).set('crab', 8).set('turret', 8) .set('bomber', 7).set('bee', 5).set('brute', 4).set('shaman', 5) .set('spore', 5).set('wisp', 5); } else if (wave >= 51) { w.set('spider', 17).set('frog', 12).set('ghost', 12).set('slime', 8) .set('bat', 8).set('archer', 7).set('crab', 8).set('turret', 8) .set('bomber', 7).set('bee', 5).set('brute', 4).set('shaman', 5) .set('spore', 5); } else if (wave >= 41) { w.set('spider', 18).set('frog', 13).set('ghost', 13).set('slime', 8) .set('bat', 8).set('archer', 8).set('crab', 8).set('turret', 8) .set('bomber', 8).set('bee', 5).set('brute', 5).set('shaman', 5) .set('spore', 5); } else if (wave >= 31) { w.set('spider', 20).set('frog', 14).set('ghost', 14).set('slime', 8) .set('bat', 8).set('archer', 8).set('crab', 8).set('turret', 8) .set('bomber', 8).set('bee', 5).set('brute', 5); } else if (wave >= 21) { w.set('spider', 22).set('frog', 15).set('ghost', 15).set('slime', 8) .set('bat', 8).set('archer', 8).set('crab', 8).set('turret', 8) .set('bomber', 8); } else if (wave >= 11) { w.set('spider', 28).set('frog', 18).set('ghost', 18) .set('bat', 9); } else if (wave >= 3) { w.set('spider', 60).set('frog', 40); } else { w.set('spider', 100); } return w; } private isBossWave(wave: number): boolean { return this.bossWaves.has(wave); } private spawnBossForWave(wave: number) { const entry = this.bossWaves.get(wave); if (!entry) return; const enemy = entry.spawn(); 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); } spawnEnemyInstance(enemy: Enemy, x: number, z: number, y: number) { // Gegner werden erst NACH den Boss-Wellen stärker: pro besiegten Boss // (jede 10. Welle) +20% HP, davor Basis-HP. Bosse bleiben unskaliert. if (!enemy.isBoss) { const bossGates = Math.floor(Math.max(0, this.spawns - 1) / 10); if (bossGates > 0) { const scale = 1 + 0.2 * bossGates; enemy.health = Math.round(enemy.health * scale); enemy.maxHealth = enemy.health; } } enemy.body.position.set(x, y, z); this.scene.add(enemy.body); this.enemies.push(enemy); } spawnEnemyProjectile(from: THREE.Vector3, target: THREE.Vector3, damage: number) { const projectile = new EnemyProjectile(from, target, damage); this.scene.add(projectile.body); this.projectiles.push(projectile); } destroyProjectilesInArc(pos: THREE.Vector3, aim: THREE.Vector3, range: number) { for (let i = this.projectiles.length - 1; i >= 0; i--) { const p = this.projectiles[i]; if (isInSlashArc(pos, aim, p.body.position, range)) { p.dispose(); this.projectiles.splice(i, 1); } } } private spawnCross(position: THREE.Vector3) { if (Math.random() > 0.9) { const cross = new Cross(position.clone()); this.scene.add(cross.body); this.crosses.push(cross); } } private updateGroundPoint() { const raycaster = new THREE.Raycaster(); raycaster.setFromCamera(new THREE.Vector2(this.mouseX, this.mouseY), this.camera); const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0); const intersection = new THREE.Vector3(); if (raycaster.ray.intersectPlane(plane, intersection)) { intersection.y = 0; intersection.clamp( new THREE.Vector3(-48, 0, -48), new THREE.Vector3(48, 0, 48) ); this.groundPoint.copy(intersection); } } private updatePlayerMovement(dt: number) { if (this.state !== 'playing') { this.player.body.position.y = 0.5; return; } const camFwd = new THREE.Vector3(); this.camera.getWorldDirection(camFwd); camFwd.y = 0; camFwd.normalize(); const camLeft = new THREE.Vector3(); camLeft.crossVectors(new THREE.Vector3(0, 1, 0), camFwd).normalize(); const p = this.player; let speed = p.stats.moveSpeed; if (p.slowTimer > 0) speed *= p.slowFactor; let isMoving = false; if (p.stunTime > 0) { // Gestunnt (Stampf-Schockwelle): keine Eingaben p.velocity.set(0, 0, 0); } else if (p.rootTime > 0) { // Netz-Fessel (Spinnenkönigin): keine Bewegung, Angriffe bleiben möglich 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); p.onGround = true; p.body.position.y = 0.5; } else { 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 && p.getCurrentAnimation() !== 'walk') { p.playAnimation('walk'); } else if (!isMoving && p.getCurrentAnimation() !== 'stand') { p.playAnimation('stand'); } // Jump / gravity if (!p.onGround && p.pullTime <= 0 && p.stunTime <= 0 && p.rootTime <= 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 p.body.position.x += p.velocity.x * dt; p.body.position.z += p.velocity.z * dt; this.clampToArena(p.body.position); // Labyrinth-Wände blockieren den Spieler if (this.mazeActive && this.maze) { this.maze.collidePlayer(p.body.position, 0.7); } // Look at ground point p.body.lookAt( this.groundPoint.x, p.body.position.y, this.groundPoint.z ); } clampToArena(pos: THREE.Vector3) { pos.x = Math.max(-47, Math.min(47, pos.x)); 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) { if (this.mazeActive) return; 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') { enemy.updateAnimation(dt); continue; } if (enemy.rootTime > 0) { enemy.rootTime -= dt; enemy.updateAnimation(dt); continue; } enemy.update(dt, this); enemy.updateAnimation(dt); } } private updateFireballs(dt: number) { for (let i = this.fireballs.length - 1; i >= 0; i--) { const fb = this.fireballs[i]; fb.lifetime -= dt; if (fb.exploding) { fb.timer += dt; if (fb.timer > 0.2 && !fb.damageDone) { fb.damageDone = true; for (const enemy of this.enemies) { if (enemy.dead) continue; const dist = enemy.body.position.distanceTo(fb.body.position); if (dist < fb.explosionRadius) { enemy.takeDamage(fb.damage, this, true, fb.body.position); } } } if (fb.timer > 2) { this.scene.remove(fb.body); this.fireballs.splice(i, 1); } continue; } fb.body.position.x += fb.direction.x * dt; fb.body.position.z += fb.direction.z * dt; fb.updateTrail(dt, fb.body.position); for (const enemy of this.enemies) { if (enemy.dead) continue; const dist = enemy.body.position.distanceTo(fb.body.position); if (dist < 1.8) { fb.explode(this.scene); break; } } if (fb.lifetime <= 0) { fb.explode(this.scene); } } } private updateUI() { const maxHealth = this.player.stats.maxHealth; const healthPct = this.player.health / maxHealth; this.uiHealthBar.style.width = `${Math.max(0, healthPct * 250)}px`; this.uiHealthText.textContent = `${Math.ceil(this.player.health)}`; const shieldMax = this.player.stats.shield; this.uiShieldBarBg.style.display = shieldMax > 0 ? '' : 'none'; this.uiShieldBar.style.display = shieldMax > 0 ? '' : 'none'; this.uiShieldText.style.display = shieldMax > 0 ? '' : 'none'; if (shieldMax > 0) { const shieldPct = this.player.shieldHp / shieldMax; this.uiShieldBar.style.width = `${Math.max(0, shieldPct * 250)}px`; this.uiShieldText.textContent = `${Math.ceil(this.player.shieldHp)}`; } this.uiWaveLabel.textContent = `Wave ${this.spawns}`; this.uiKillsLabel.textContent = `Kills: ${this.killed}`; this.uiLevelLabel.textContent = `Level ${this.skillSystem.level}`; this.uiSeedLabel.textContent = this.seed === null ? '' : `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() ? `XP: ${Math.floor(this.skillSystem.xp)}/${this.skillSystem.xpNeeded()}` : 'MAX'; const sword = this.player.rightHandWeapon as Sword; const staff = this.player.leftHandWeapon as Staff; const lightning = this.player.skillLightning; const fbPct = Math.max(0, (staff.COOLDOWN1_TIME - staff.cooldown1) / staff.COOLDOWN1_TIME); this.uiFireballCD.style.width = `${fbPct * 100}%`; const swordPct = Math.max(0, (sword.COOLDOWN1_TIME - sword.cooldown1) / sword.COOLDOWN1_TIME); this.uiSwordCD.style.width = `${swordPct * 100}%`; const tpPct = Math.max(0, (staff.COOLDOWN2_TIME - staff.cooldown2) / staff.COOLDOWN2_TIME); this.uiTeleportCD.style.width = `${tpPct * 100}%`; const lnPct = Math.max(0, (lightning.COOLDOWN1_TIME - lightning.cooldown1) / lightning.COOLDOWN1_TIME); this.uiLightningCD.style.width = `${lnPct * 100}%`; const trapTotal = this.trapper.COOLDOWN1_TIME || 1; const trapPct = Math.max(0, (trapTotal - this.trapper.cooldown1) / trapTotal); this.uiTrapCD.style.width = `${trapPct * 100}%`; const gasTotal = this.trapper.COOLDOWN2_TIME || 1; const gasPct = Math.max(0, (gasTotal - this.trapper.cooldown2) / gasTotal); this.uiGasCD.style.width = `${gasPct * 100}%`; const wavePct = Math.min(1, this.spawnTimer / SPAWN_TIME); if (this.mazeActive) { this.uiWaveBarFill.style.width = `${Math.min(1, this.mazeTimer / this.mazeTotal) * 100}%`; this.uiWaveBarLabel.textContent = `Labyrinth: ${Math.ceil(this.mazeTimer)}s`; } else if (this.bossFightActive) { this.uiWaveBarFill.style.width = '100%'; this.uiWaveBarLabel.textContent = 'BOSS!'; } else if (this.spawnTimer >= SPAWN_TIME && this.isBossWave(this.spawns + 1)) { this.uiWaveBarFill.style.width = '100%'; this.uiWaveBarLabel.textContent = 'Gegner räumen!'; } else { this.uiWaveBarFill.style.width = `${wavePct * 100}%`; this.uiWaveBarLabel.textContent = `Next Wave: ${Math.ceil(SPAWN_TIME - this.spawnTimer)}s`; } if (this.player.health <= 0 && this.state === 'playing') { this.gameOver(); } this.uiLowHpVignette.classList.toggle( 'active', healthPct < 0.3 && healthPct > 0 ); this.updateMazeArrow(); } 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'); void el.offsetWidth; el.classList.add('hurt'); const away = new THREE.Vector3() .subVectors(this.player.body.position, sourcePos); away.y = 0; if (away.lengthSq() < 0.01) { away.set(Math.random() - 0.5, 0, Math.random() - 0.5); } away.normalize(); const geom = new THREE.SphereGeometry(0.07, 6, 6); const count = 12; for (let i = 0; i < count; i++) { const mat = new THREE.MeshBasicMaterial({ color: 0xcc2222, transparent: true, }); const mesh = new THREE.Mesh(geom, mat); mesh.position.copy(this.player.body.position); mesh.position.y += 0.5; this.scene.add(mesh); this.bloodParticles.push({ mesh, mat, vel: away.clone() .multiplyScalar(2 + Math.random() * 3) .add(new THREE.Vector3( (Math.random() - 0.5) * 1.5, 3 + Math.random() * 3, (Math.random() - 0.5) * 1.5 )), life: 0.5 + Math.random() * 0.3, }); } } private updateBloodParticles(dt: number) { for (let i = this.bloodParticles.length - 1; i >= 0; i--) { const p = this.bloodParticles[i]; p.life -= dt; if (p.life <= 0) { this.scene.remove(p.mesh); p.mesh.geometry.dispose(); p.mat.dispose(); this.bloodParticles.splice(i, 1); continue; } p.vel.y -= 14 * dt; p.mesh.position.addScaledVector(p.vel, dt); if (p.mesh.position.y < 0.06) { p.mesh.position.y = 0.06; p.vel.set(0, 0, 0); } p.mat.opacity = Math.max(0, p.life / 0.6); } } private updateTraps(dt: number) { for (let i = this.traps.length - 1; i >= 0; i--) { const trap = this.traps[i]; if (!trap.update(dt, this)) { trap.dispose(); this.traps.splice(i, 1); } } } private updateGasClouds(dt: number) { for (let i = this.gasClouds.length - 1; i >= 0; i--) { const cloud = this.gasClouds[i]; if (!cloud.update(dt, this)) { cloud.dispose(); this.gasClouds.splice(i, 1); } } } private animate() { requestAnimationFrame(() => this.animate()); const dt = Math.min(this.clock.getDelta(), 0.1); if (this.paused) { this.updateUI(); this.renderer.render(this.scene, this.camera); return; } this.updateGroundPoint(); this.updatePlayerMovement(dt); this.updateEnemies(dt); this.updateOffscreenReposition(dt); this.updateFireballs(dt); this.updateHitzones(); // Arena-Theme weich einfärben (Boden, Wände, Nebel, Licht) this.arena.update(dt); const themeT = Math.min(1, dt * 1.5); (this.scene.fog as THREE.Fog).color.lerp(this.themeFogTarget, themeT); (this.scene.background as THREE.Color).lerp(this.themeFogTarget, themeT); this.playerLight.color.lerp(this.themeLightTarget, themeT); // Update slash effects for (let i = this.slashEffects.length - 1; i >= 0; i--) { if (!this.slashEffects[i].update(dt)) { this.slashEffects.splice(i, 1); } } this.updateBloodParticles(dt); // Schadenszahlen aktualisieren for (let i = this.damageNumbers.length - 1; i >= 0; i--) { if (!this.damageNumbers[i].update(dt)) { this.damageNumbers[i].dispose(this.scene); this.damageNumbers.splice(i, 1); } } if (this.aura) this.aura.update(dt, this); if (this.boomerang) this.boomerang.update(dt, this); this.updateTraps(dt); this.updateGasClouds(dt); // Update animations this.player.updateAnimations(dt); this.player.rightHandWeapon.updateAnimation(dt); // Spawn enemies in waves if (this.state === 'playing') { if (this.mazeActive) { // Labyrinth-Zwischenboss: ersetzt die Welle komplett this.updateMaze(dt); } else { const arenaCleared = this.enemies.every(e => e.dead); if (this.bossFightActive) { // Boss besiegt -> naechste Welle startet if (arenaCleared) { this.bossFightActive = false; this.spawns++; this.spawnTimer = 0; // Arena verändert sich nach jedem besiegten Boss if (this.arenaStage < 7) { this.arenaStage++; this.themeFogTarget.copy(this.arena.applyTheme(this.arenaStage)); this.themeLightTarget.setHex(Game.THEME_LIGHTS[this.arenaStage]); this.triggerCameraShake(0.4, 0.4); playSound('explosion', 0.6); } } } else if (this.spawnTimer >= SPAWN_TIME) { if (this.isBossWave(this.spawns + 1)) { // Boss-Welle startet erst, wenn alle Gegner tot sind if (arenaCleared) { this.spawns++; this.spawnBossForWave(this.spawns); this.bossFightActive = true; this.spawnTimer = 0; } } else if (this.isMazeWave(this.spawns + 1)) { // Labyrinth-Zwischenboss startet (ersetzt die Welle) this.spawns++; this.startMazeWave(); this.spawnTimer = 0; } else { this.spawns++; for (let i = 0; i < this.spawns; i++) { this.spawnEnemy(); } this.spawnTimer = 0; } } else { this.spawnTimer += dt; } } } // Check close encounters (enemy touching player) if (this.state === 'playing') { const thorns = this.player.stats.thornDamage; for (const enemy of this.enemies) { if (enemy.dead) continue; const dist = enemy.body.position.distanceTo(this.player.body.position); if (dist < enemy.contactRadius) { this.player.damage(enemy.contactDps * dt, enemy.body.position); if (thorns > 0) { enemy.takeDamage(thorns * dt, this, false); } } } this.player.updatePassives(dt); } // Enemy projectiles if (this.state === 'playing') { for (let i = this.projectiles.length - 1; i >= 0; i--) { const p = this.projectiles[i]; const alive = p.update(dt, this); if (!alive || p.dead) { p.dispose(); this.projectiles.splice(i, 1); } } } // Cross collisions for (let i = this.crosses.length - 1; i >= 0; i--) { const cross = this.crosses[i]; cross.update(dt); if (this.state === 'playing') { const dist = cross.body.position.distanceTo(this.player.body.position); if (dist < 2) { this.player.heal(25); this.scene.remove(cross.body); this.crosses.splice(i, 1); } } } // Update cooldowns this.player.rightHandWeapon.reduceCooldowns(dt); this.player.leftHandWeapon.reduceCooldowns(dt); this.player.skillLightning.reduceCooldowns(dt); this.trapper.reduceCooldowns(dt); // Camera follows player this.camera.position.set( this.player.body.position.x, 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, this.player.body.position.z ); // Lichtschein folgt dem Spieler this.playerLight.position.set( this.player.body.position.x, 6, this.player.body.position.z ); // Clean dead enemies for (let i = this.enemies.length - 1; i >= 0; i--) { const enemy = this.enemies[i]; if (enemy.dead) { enemy.deathTimer += dt; if (enemy.deathTimer > 10) { this.scene.remove(enemy.body); this.enemies.splice(i, 1); } } } this.updateUI(); this.renderer.render(this.scene, this.camera); } removeEnemy(enemy: Enemy) { enemy.dead = true; this.killed++; if (enemy.guaranteedDrop) { const cross = new Cross(enemy.body.position.clone()); this.scene.add(cross.body); this.crosses.push(cross); } else { this.spawnCross(enemy.body.position); } if (this.state === 'playing' || this.state === 'levelup') { const levels = this.skillSystem.addXp(enemy.xp); if (levels > 0) { this.pendingPicks += levels; if (this.state === 'playing') { this.openLevelUp(); } } } } }