Add skill system, level-ups, and new enemy types

This commit is contained in:
2026-08-17 16:38:58 +02:00
parent 4751d428a7
commit 6f207ba1a0
23 changed files with 2192 additions and 83 deletions
+355 -27
View File
@@ -3,17 +3,27 @@ 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 { Boss } from './Boss';
import { Crab } from './Crab';
import { Turret } from './Turret';
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 } from './SwordTrail';
import { SlashEffect, isInSlashArc } from './SwordTrail';
import { SkillSystem, getSkill, type SkillOffer } from './Skills';
import { initSounds, playSound, resumeContext } from './SoundManager';
const SPAWN_TIME = 10;
type GameState = 'menu' | 'playing' | 'gameover';
type GameState = 'menu' | 'playing' | 'gameover' | 'levelup';
export class Game {
scene: THREE.Scene;
@@ -26,8 +36,15 @@ export class Game {
fireballs: Fireball[] = [];
crosses: Cross[] = [];
slashEffects: SlashEffect[] = [];
projectiles: EnemyProjectile[] = [];
state: GameState = 'menu';
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();
@@ -53,14 +70,30 @@ export class Game {
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 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 uiOfferTitles: HTMLElement[] = [];
private uiOfferDescs: HTMLElement[] = [];
private mouseOnCanvas = false;
constructor() {
@@ -120,23 +153,58 @@ export class Game {
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.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());
for (let i = 0; i < 3; i++) {
const card = document.getElementById(`offer-${i}`)!;
card.addEventListener('click', () => this.selectOffer(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-restart')!.addEventListener('click', () => this.restart());
document.getElementById('btn-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);
canvas.addEventListener('contextmenu', e => e.preventDefault());
window.addEventListener('contextmenu', e => e.preventDefault());
}
private logState(next: GameState, reason: string) {
console.log(`[Wackelpeter] state: ${this.state}${next} (${reason})`);
}
private setupInput() {
@@ -149,9 +217,25 @@ export class Game {
if (e.code === 'KeyH') {
this.showHitzones = !this.showHitzones;
}
if (e.code === 'KeyQ' && this.state === 'playing') {
this.player.skillLightning.skill1(this.groundPoint, this.player);
}
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.startGame();
else if (this.state === 'gameover') this.restart();
else if (
this.state === 'gameover' &&
performance.now() - this.gameOverAt > 1500
) {
this.restart();
}
}
});
window.addEventListener('keyup', (e) => {
@@ -180,11 +264,9 @@ export class Game {
private handleMouseRelease(button: number) {
if (this.state !== 'playing') return;
if (button === 0) {
this.player.leftHandWeapon.skill1(this.groundPoint, this.player);
} else if (button === 1) {
this.player.switchHands();
} else if (button === 2) {
this.player.rightHandWeapon.skill1(this.groundPoint, this.player);
} else if (button === 2) {
this.player.leftHandWeapon.skill1(this.groundPoint, this.player);
}
}
@@ -308,15 +390,31 @@ export class Game {
}
startGame() {
if (this.state === 'playing') return;
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.logState('playing', 'startGame');
this.state = 'playing';
sessionStorage.setItem('wackelpeter-run', '1');
this.uiMainMenu.style.opacity = '0';
this.uiMainMenu.style.pointerEvents = 'none';
this.uiGameOver.style.opacity = '0';
this.uiGameOverPanel.style.transition = 'opacity 0.3s ease';
this.uiGameOverPanel.style.opacity = '0';
this.uiGameOverPanel.style.pointerEvents = 'none';
this.blurFocus();
}
private blurFocus() {
const el = document.activeElement;
if (el instanceof HTMLElement) el.blur();
}
restart() {
@@ -325,20 +423,27 @@ export class Game {
showMenu() {
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.uiMainMenu.style.opacity = '1';
this.uiMainMenu.style.pointerEvents = 'auto';
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}`;
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';
@@ -361,6 +466,9 @@ export class Game {
for (const slash of this.slashEffects) slash.dispose();
this.slashEffects = [];
for (const p of this.projectiles) p.dispose();
this.projectiles = [];
for (const [enemy, ring] of this.enemyHitRings) {
this.scene.remove(ring);
ring.geometry.dispose();
@@ -383,32 +491,185 @@ export class Game {
this.spawnTimer = 0;
this.spawns = 0;
this.killed = 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();
}
private spawnEnemy() {
const ghostChance = this.spawns >= 3
? Math.min(0.15 + (this.spawns - 3) * 0.08, 0.6)
: 0;
const isGhost = Math.random() < ghostChance;
const enemy = isGhost ? new Ghost() : new Spider();
onDamageDealt(damage: number) {
if (this.state === 'playing' && this.player.stats.lifesteal > 0) {
this.player.heal(damage * this.player.stats.lifesteal);
}
}
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;
title.textContent = `${skill.name}${isNew ? ' (NEU)' : ` · Stufe ${offer.nextLevel}`}`;
desc.textContent = skill.describe(offer.nextLevel);
card.style.display = '';
card.classList.remove('selected');
} else {
card.style.display = 'none';
}
}
this.uiConfirmBtn.disabled = true;
this.uiLevelUp.style.opacity = '1';
this.uiLevelUp.style.pointerEvents = 'auto';
}
private hideLevelUp() {
this.uiLevelUp.style.opacity = '0';
this.uiLevelUp.style.pointerEvents = 'none';
}
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 confirmOffer() {
if (this.state !== 'levelup' || this.selectedOffer < 0) return;
const offer = this.offers[this.selectedOffer];
this.selectedOffer = -1;
this.skillSystem.apply(offer.id);
this.player.applySkillSystem(this.skillSystem);
this.syncSkillVisibility();
this.blurFocus();
if (this.pendingPicks > 0) {
this.pendingPicks--;
this.openLevelUp();
} else if (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';
}
private spawnEnemy() {
const x = (Math.random() - 0.5) * 80;
const z = (Math.random() - 0.5) * 80;
enemy.body.position.set(x, isGhost ? 1.0 : 0.1, z);
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;
}
}
private enemyWeights(wave: number): Map<string, number> {
const w = new Map<string, number>();
if (wave >= 7) {
w.set('spider', 25).set('ghost', 15).set('frog', 15).set('slime', 10)
.set('bat', 10).set('turret', 10).set('crab', 10).set('bee', 5);
} else if (wave >= 6) {
w.set('spider', 35).set('ghost', 20).set('frog', 15).set('slime', 10)
.set('bat', 10).set('turret', 10);
} else if (wave >= 5) {
w.set('spider', 45).set('ghost', 20).set('frog', 15).set('slime', 10)
.set('bat', 10);
} else if (wave >= 4) {
w.set('spider', 55).set('ghost', 25).set('frog', 20);
} else if (wave >= 3) {
w.set('spider', 75).set('ghost', 25);
} else {
w.set('spider', 100);
}
return w;
}
spawnEnemyInstance(enemy: Enemy, x: number, z: number, y: number) {
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());
@@ -452,7 +713,7 @@ export class Game {
if (this.keys.has('KeyA') || this.keys.has('ArrowLeft')) moveDir.add(camLeft);
if (this.keys.has('KeyD') || this.keys.has('ArrowRight')) moveDir.sub(camLeft);
let speed = 7.5;
let speed = this.player.stats.moveSpeed;
let isMoving = false;
if (moveDir.length() > 0) {
moveDir.normalize();
@@ -521,8 +782,8 @@ export class Game {
for (const enemy of this.enemies) {
if (enemy.dead) continue;
const dist = enemy.body.position.distanceTo(fb.body.position);
if (dist < 8) {
enemy.takeDamage(100, this);
if (dist < fb.explosionRadius) {
enemy.takeDamage(fb.damage, this, true, fb.body.position);
}
}
}
@@ -553,15 +814,37 @@ export class Game {
}
private updateUI() {
const healthPct = this.player.health / this.player.MAX_HEALTH;
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)}%`;
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}`;
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}%`;
@@ -572,6 +855,9 @@ export class Game {
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 wavePct = this.spawnTimer / SPAWN_TIME;
this.uiWaveBarFill.style.width = `${wavePct * 100}%`;
this.uiWaveBarLabel.textContent = `Next Wave: ${Math.ceil(SPAWN_TIME - this.spawnTimer)}s`;
@@ -608,8 +894,15 @@ export class Game {
this.spawnTimer += dt;
if (this.spawnTimer >= SPAWN_TIME) {
this.spawns++;
for (let i = 0; i < this.spawns; i++) {
this.spawnEnemy();
if (this.spawns % 5 === 0) {
const x = (Math.random() - 0.5) * 80;
const z = (Math.random() - 0.5) * 80;
this.spawnEnemyInstance(new Boss(), x, z, 0.15);
playSound('spawn', 0.8);
} else {
for (let i = 0; i < this.spawns; i++) {
this.spawnEnemy();
}
}
this.spawnTimer = 0;
}
@@ -617,11 +910,28 @@ export class Game {
// 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 < 2) {
this.player.health -= 25 * dt;
if (dist < enemy.contactRadius) {
this.player.damage(enemy.contactDps * dt);
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);
}
}
}
@@ -643,6 +953,7 @@ export class Game {
// Update cooldowns
this.player.rightHandWeapon.reduceCooldowns(dt);
this.player.leftHandWeapon.reduceCooldowns(dt);
this.player.skillLightning.reduceCooldowns(dt);
// Camera follows player
this.camera.position.set(
@@ -675,6 +986,23 @@ export class Game {
removeEnemy(enemy: Enemy) {
enemy.dead = true;
this.killed++;
this.spawnCross(enemy.body.position);
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 leveled = this.skillSystem.addXp(enemy.xp);
if (leveled) {
if (this.state === 'playing') {
this.openLevelUp();
} else {
this.pendingPicks++;
}
}
}
}
}