1899 lines
65 KiB
TypeScript
1899 lines
65 KiB
TypeScript
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<string> = new Set();
|
|
mouseButtons: Set<number> = new Set();
|
|
private prevMouseButtons: Set<number> = 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<Enemy, number>();
|
|
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<number, { name: string; spawn: () => 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<Enemy, THREE.Mesh> = new Map();
|
|
private fireballHitRings: Map<Fireball, THREE.Mesh> = new Map();
|
|
private crossHitRings: Map<Cross, THREE.Mesh> = 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<string, number> {
|
|
const w = new Map<string, number>();
|
|
// 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();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|