+
Level Up!
+
+
Pausiert
+
+
+
+
- WASD: Move · Space: Jump · Mouse: Aim · Left Click: Sword · Right Click: Fireball · Q: Lightning · E: Teleport
+ WASD: Move · Space: Jump · Mouse: Aim · Left Click: Sword · Right Click: Fireball · Q/E/1-4: Skills · ESC: Pause
diff --git a/web/src/Aura.ts b/web/src/Aura.ts
new file mode 100644
index 0000000..819447e
--- /dev/null
+++ b/web/src/Aura.ts
@@ -0,0 +1,99 @@
+import * as THREE from 'three';
+import type { Game } from './Game';
+import { auraRadius, auraDamage, auraPush } from './Skills';
+
+const TICK = 0.5;
+
+export class Aura {
+ private group: THREE.Group;
+ private fill: THREE.Mesh;
+ private ring: THREE.Mesh;
+ private tickTimer = 0;
+ private pulse = 0;
+
+ constructor(scene: THREE.Scene) {
+ this.group = new THREE.Group();
+
+ this.fill = new THREE.Mesh(
+ new THREE.CircleGeometry(1, 48),
+ new THREE.MeshBasicMaterial({
+ color: 0x66ccff,
+ transparent: true,
+ opacity: 0.12,
+ depthWrite: false,
+ })
+ );
+ this.fill.rotation.x = -Math.PI / 2;
+ this.fill.position.y = 0.05;
+ this.fill.renderOrder = 998;
+ this.group.add(this.fill);
+
+ this.ring = new THREE.Mesh(
+ new THREE.RingGeometry(0.93, 1, 48),
+ new THREE.MeshBasicMaterial({
+ color: 0x88ddff,
+ transparent: true,
+ opacity: 0.5,
+ side: THREE.DoubleSide,
+ depthWrite: false,
+ })
+ );
+ this.ring.rotation.x = -Math.PI / 2;
+ this.ring.position.y = 0.06;
+ this.ring.renderOrder = 999;
+ this.group.add(this.ring);
+
+ scene.add(this.group);
+ }
+
+ update(dt: number, game: Game) {
+ const level = game.skillSystem.getLevel('aura');
+ if (level <= 0) return;
+
+ const radius = auraRadius(level);
+ this.group.position.x = game.player.body.position.x;
+ this.group.position.z = game.player.body.position.z;
+ this.group.scale.setScalar(radius);
+
+ if (this.pulse > 0) {
+ this.pulse -= dt * 2.5;
+ const p = Math.max(0, this.pulse);
+ const s = 1 + p * 0.12;
+ this.fill.scale.setScalar(s);
+ this.ring.scale.setScalar(s);
+ (this.ring.material as THREE.MeshBasicMaterial).opacity = 0.5 + p * 0.4;
+ }
+
+ this.tickTimer -= dt;
+ if (this.tickTimer > 0) return;
+ this.tickTimer = TICK;
+
+ const damage = auraDamage(level);
+ const push = auraPush(level);
+ const center = game.player.body.position;
+ for (const enemy of game.enemies) {
+ if (enemy.dead) continue;
+ const dist = enemy.body.position.distanceTo(center);
+ if (dist > radius + 0.4) continue;
+ enemy.takeDamage(damage, game, false, center);
+ const dir = new THREE.Vector3()
+ .subVectors(enemy.body.position, center);
+ dir.y = 0;
+ if (dir.lengthSq() < 0.01) {
+ dir.set(Math.random() - 0.5, 0, Math.random() - 0.5);
+ }
+ dir.normalize();
+ enemy.body.position.addScaledVector(dir, push);
+ game.clampToArena(enemy.body.position);
+ }
+ this.pulse = 1;
+ }
+
+ dispose(scene: THREE.Scene) {
+ scene.remove(this.group);
+ this.fill.geometry.dispose();
+ (this.fill.material as THREE.Material).dispose();
+ this.ring.geometry.dispose();
+ (this.ring.material as THREE.Material).dispose();
+ }
+}
diff --git a/web/src/Boomerang.ts b/web/src/Boomerang.ts
new file mode 100644
index 0000000..b69a083
--- /dev/null
+++ b/web/src/Boomerang.ts
@@ -0,0 +1,109 @@
+import * as THREE from 'three';
+import type { Game } from './Game';
+import type { Enemy } from './Enemy';
+import { boomerangDamage, boomerangCount } from './Skills';
+
+const ORBIT_RADIUS = 2.8;
+const ORBIT_HEIGHT = 0.9;
+const ORBIT_SPEED = 2.4;
+const SPIN_SPEED = 14;
+const HIT_CD = 0.6;
+const HIT_RADIUS = 0.9;
+
+export class Boomerang {
+ private meshes: THREE.Mesh[] = [];
+ private angle = 0;
+ private hitTimers = new Map
();
+
+ constructor(scene: THREE.Scene) {
+ const shape = new THREE.Shape();
+ const outer = 1.05;
+ const inner = 0.5;
+ const half = (58 * Math.PI) / 180;
+ const capR = (outer - inner) / 2;
+ const mid = (outer + inner) / 2;
+
+ shape.moveTo(outer * Math.cos(-half), outer * Math.sin(-half));
+ shape.absarc(0, 0, outer, -half, half, false);
+ shape.absarc(
+ mid * Math.cos(half), mid * Math.sin(half),
+ capR, half, half + Math.PI, false
+ );
+ shape.absarc(0, 0, inner, half, -half, true);
+ shape.absarc(
+ mid * Math.cos(-half), mid * Math.sin(-half),
+ capR, -half + Math.PI, -half, true
+ );
+ shape.closePath();
+ const geom = new THREE.ExtrudeGeometry(shape, {
+ depth: 0.06,
+ bevelEnabled: true,
+ bevelThickness: 0.02,
+ bevelSize: 0.02,
+ bevelSegments: 2,
+ });
+ geom.center();
+ const mat = new THREE.MeshToonMaterial({ color: 0xc98a3d });
+
+ for (let i = 0; i < 3; i++) {
+ const mesh = new THREE.Mesh(geom, mat);
+ mesh.rotation.x = -Math.PI / 2;
+ mesh.castShadow = true;
+ mesh.visible = false;
+ scene.add(mesh);
+ this.meshes.push(mesh);
+ }
+ }
+
+ setLevel(level: number) {
+ const count = level > 0 ? boomerangCount(level) : 0;
+ for (let i = 0; i < this.meshes.length; i++) {
+ this.meshes[i].visible = i < count;
+ }
+ }
+
+ update(dt: number, game: Game) {
+ const level = game.skillSystem.getLevel('boomerang');
+ if (level <= 0) return;
+ const count = boomerangCount(level);
+ const damage = boomerangDamage(level);
+
+ this.angle += ORBIT_SPEED * dt;
+ const center = game.player.body.position;
+
+ for (const [enemy, t] of this.hitTimers) {
+ this.hitTimers.set(enemy, t - dt);
+ }
+
+ for (let i = 0; i < count; i++) {
+ const mesh = this.meshes[i];
+ const a = this.angle + (i / count) * Math.PI * 2;
+ mesh.position.set(
+ center.x + Math.cos(a) * ORBIT_RADIUS,
+ ORBIT_HEIGHT + Math.sin(this.angle * 2 + i) * 0.15,
+ center.z + Math.sin(a) * ORBIT_RADIUS
+ );
+ mesh.rotation.z += SPIN_SPEED * dt;
+
+ for (const enemy of game.enemies) {
+ if (enemy.dead) continue;
+ const timer = this.hitTimers.get(enemy) ?? 0;
+ if (timer > 0) continue;
+ const dist = enemy.body.position.distanceTo(mesh.position);
+ if (dist < HIT_RADIUS + 0.5) {
+ this.hitTimers.set(enemy, HIT_CD);
+ enemy.takeDamage(damage, game, true, mesh.position);
+ }
+ }
+ }
+ }
+
+ dispose(scene: THREE.Scene) {
+ for (const mesh of this.meshes) {
+ scene.remove(mesh);
+ mesh.geometry.dispose();
+ (mesh.material as THREE.Material).dispose();
+ }
+ this.meshes = [];
+ }
+}
diff --git a/web/src/Enemy.ts b/web/src/Enemy.ts
index 7f69ac7..5f6362a 100644
--- a/web/src/Enemy.ts
+++ b/web/src/Enemy.ts
@@ -7,6 +7,7 @@ export abstract class Enemy {
dead = false;
deathTimer = 0;
knockback = 0;
+ rootTime = 0;
health = 100;
speed = 6;
xp = 10;
diff --git a/web/src/Game.ts b/web/src/Game.ts
index 8764af8..0b9acea 100644
--- a/web/src/Game.ts
+++ b/web/src/Game.ts
@@ -18,12 +18,20 @@ import { Fireball } from './Fireball';
import { Cross, initCrossModel } from './Cross';
import { Arena } from './Arena';
import { SlashEffect, isInSlashArc } from './SwordTrail';
-import { SkillSystem, getSkill, type SkillOffer } from './Skills';
+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';
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 {
@@ -38,8 +46,15 @@ export class Game {
crosses: Cross[] = [];
slashEffects: SlashEffect[] = [];
projectiles: EnemyProjectile[] = [];
+ traps: Trap[] = [];
+ gasClouds: GasCloud[] = [];
+ 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[] = [];
@@ -98,6 +113,15 @@ export class Game {
private uiOfferDescs: HTMLElement[] = [];
private uiDamageVignette!: HTMLElement;
private uiLowHpVignette!: 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: {
@@ -189,6 +213,14 @@ export class Game {
this.uiConfirmBtn.addEventListener('click', () => this.confirmOffer());
this.uiDamageVignette = document.getElementById('damage-vignette')!;
this.uiLowHpVignette = document.getElementById('low-hp-vignette')!;
+ 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}`)!;
@@ -201,6 +233,9 @@ export class Game {
document.getElementById('btn-start')!.addEventListener('click', () => this.startGame());
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') {
@@ -224,6 +259,20 @@ export class Game {
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();
+ }
+ e.preventDefault();
+ return;
+ }
if (e.code === 'Space' && this.state === 'playing') {
this.player.jump();
e.preventDefault();
@@ -231,8 +280,9 @@ 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);
+ 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);
@@ -254,9 +304,6 @@ export class Game {
});
window.addEventListener('keyup', (e) => {
this.keys.delete(e.code);
- if (e.code === 'KeyE' && this.state === 'playing') {
- this.player.leftHandWeapon.skill2(this.groundPoint, this.player);
- }
});
window.addEventListener('mousedown', (e) => {
@@ -276,7 +323,7 @@ export class Game {
}
private handleMouseRelease(button: number) {
- if (this.state !== 'playing') return;
+ if (this.state !== 'playing' || this.paused) return;
if (button === 0) {
this.player.rightHandWeapon.skill1(this.groundPoint, this.player);
} else if (button === 2) {
@@ -284,6 +331,33 @@ export class Game {
}
}
+ 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.35);
this.scene.add(ambient);
@@ -416,13 +490,17 @@ export class Game {
this.resetWorld();
this.logState('playing', 'startGame');
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.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();
}
@@ -435,7 +513,34 @@ export class Game {
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';
@@ -445,8 +550,10 @@ export class Game {
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();
}
@@ -461,6 +568,7 @@ export class Game {
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);
}
@@ -483,6 +591,25 @@ export class Game {
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 = [];
+
+ 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();
@@ -556,7 +683,12 @@ export class Game {
const isNew = offer.nextLevel === 1;
this.uiOfferIcons[i].innerHTML = skillIcon(offer.id);
title.textContent = `${skill.name}${isNew ? ' (NEU)' : ` · Stufe ${offer.nextLevel}`}`;
- desc.textContent = skill.describe(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 {
@@ -564,6 +696,7 @@ export class Game {
}
}
this.uiConfirmBtn.disabled = true;
+ this.uiLevelUp.style.visibility = 'visible';
this.uiLevelUp.style.opacity = '1';
this.uiLevelUp.style.pointerEvents = 'auto';
}
@@ -571,6 +704,7 @@ export class Game {
private hideLevelUp() {
this.uiLevelUp.style.opacity = '0';
this.uiLevelUp.style.pointerEvents = 'none';
+ this.uiLevelUp.style.visibility = 'hidden';
}
private selectOffer(index: number) {
@@ -588,6 +722,10 @@ export class Game {
const offer = this.offers[this.selectedOffer];
this.selectedOffer = -1;
this.skillSystem.apply(offer.id);
+ if (HOTBAR_SKILLS.includes(offer.id) && !this.hotbar.includes(offer.id)) {
+ const free = this.hotbar.indexOf(null);
+ if (free >= 0) this.hotbar[free] = offer.id;
+ }
this.player.applySkillSystem(this.skillSystem);
this.syncSkillVisibility();
this.blurFocus();
@@ -610,6 +748,30 @@ export class Game {
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() {
@@ -780,6 +942,11 @@ export class Game {
enemy.updateAnimation(dt);
continue;
}
+ if (enemy.rootTime > 0) {
+ enemy.rootTime -= dt;
+ enemy.updateAnimation(dt);
+ continue;
+ }
enemy.update(dt, this);
enemy.updateAnimation(dt);
}
@@ -873,6 +1040,14 @@ export class Game {
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 = this.spawnTimer / SPAWN_TIME;
this.uiWaveBarFill.style.width = `${wavePct * 100}%`;
this.uiWaveBarLabel.textContent = `Next Wave: ${Math.ceil(SPAWN_TIME - this.spawnTimer)}s`;
@@ -947,11 +1122,37 @@ export class Game {
}
}
+ 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);
@@ -967,6 +1168,11 @@ export class Game {
this.updateBloodParticles(dt);
+ 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);
@@ -1036,6 +1242,7 @@ export class Game {
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(
diff --git a/web/src/GasCloud.ts b/web/src/GasCloud.ts
new file mode 100644
index 0000000..9b96ba3
--- /dev/null
+++ b/web/src/GasCloud.ts
@@ -0,0 +1,79 @@
+import * as THREE from 'three';
+import type { Game } from './Game';
+import { gasDamage, gasRadius, gasDuration } from './Skills';
+
+const TICK = 0.5;
+
+export class GasCloud {
+ body: THREE.Group;
+ private level: number;
+ private life: number;
+ private duration: number;
+ private radius: number;
+ private tickTimer = 0;
+ private puffs: THREE.Mesh[] = [];
+
+ constructor(position: THREE.Vector3, level: number) {
+ this.level = level;
+ this.radius = gasRadius(level);
+ this.duration = gasDuration(level);
+ this.life = this.duration;
+ this.body = new THREE.Group();
+ this.body.position.copy(position);
+ this.body.position.y = 0;
+
+ const puffGeom = new THREE.SphereGeometry(1, 16, 12);
+ const puffMat = new THREE.MeshToonMaterial({
+ color: 0x66bb44,
+ transparent: true,
+ opacity: 0.3,
+ depthWrite: false,
+ });
+
+ for (let i = 0; i < 5; i++) {
+ const puff = new THREE.Mesh(puffGeom, puffMat);
+ const a = (i / 5) * Math.PI * 2;
+ puff.position.set(
+ Math.cos(a) * this.radius * 0.35,
+ 0.6 + Math.random() * 0.5,
+ Math.sin(a) * this.radius * 0.35
+ );
+ puff.scale.setScalar(this.radius * (0.3 + Math.random() * 0.2));
+ this.body.add(puff);
+ this.puffs.push(puff);
+ }
+ }
+
+ update(dt: number, game: Game): boolean {
+ this.life -= dt;
+ if (this.life <= 0) return false;
+
+ const fade = Math.min(1, this.life / 0.6);
+ for (const puff of this.puffs) {
+ (puff.material as THREE.MeshToonMaterial).opacity = 0.3 * fade;
+ puff.rotation.y += dt * 0.2;
+ }
+
+ this.tickTimer -= dt;
+ if (this.tickTimer <= 0) {
+ this.tickTimer = TICK;
+ const damage = gasDamage(this.level) * TICK;
+ for (const enemy of game.enemies) {
+ if (enemy.dead) continue;
+ const dist = enemy.body.position.distanceTo(this.body.position);
+ if (dist < this.radius) {
+ enemy.takeDamage(damage, game, false, this.body.position);
+ }
+ }
+ }
+ return true;
+ }
+
+ dispose() {
+ this.body.removeFromParent();
+ for (const puff of this.puffs) {
+ puff.geometry.dispose();
+ (puff.material as THREE.Material).dispose();
+ }
+ }
+}
diff --git a/web/src/SkillIcons.ts b/web/src/SkillIcons.ts
index 855caa4..80c72d7 100644
--- a/web/src/SkillIcons.ts
+++ b/web/src/SkillIcons.ts
@@ -203,6 +203,94 @@ const ICONS: Record = {
`,
+
+ aura: `
+ `,
+
+ boomerang: `
+ `,
+
+ trap: `
+ `,
+
+ gascloud: `
+ `,
};
export function skillIcon(id: string): string {
diff --git a/web/src/Skills.ts b/web/src/Skills.ts
index 2bbe645..49eeb0f 100644
--- a/web/src/Skills.ts
+++ b/web/src/Skills.ts
@@ -13,6 +13,8 @@ export interface SkillOffer {
nextLevel: number;
}
+export const HOTBAR_SKILLS = ['chainlightning', 'teleport', 'trap', 'gascloud'];
+
export const SKILLS: SkillDef[] = [
{
id: 'fireball',
@@ -93,6 +95,36 @@ export const SKILLS: SkillDef[] = [
maxLevel: 3,
describe: (l) => `Bewegungsgeschwindigkeit ${(7.5 + l).toFixed(1)}`,
},
+ {
+ id: 'aura',
+ name: 'Aura',
+ type: 'passive',
+ maxLevel: 5,
+ describe: (l) => l === 1
+ ? 'Schädigt Gegner um dich herum und stößt sie zurück'
+ : `Radius ${auraRadius(l).toFixed(1)} · ${auraDamage(l)} DMG/0,5s`,
+ },
+ {
+ id: 'boomerang',
+ name: 'Bumerang',
+ type: 'passive',
+ maxLevel: 5,
+ describe: (l) => `${boomerangCount(l)}× kreisender Bumerang · ${boomerangDamage(l)} DMG`,
+ },
+ {
+ id: 'trap',
+ name: 'Falle',
+ type: 'active',
+ maxLevel: 5,
+ describe: (l) => `Hält Gegner ${trapHold(l)}s fest (${trapDamage(l)} DMG, ${trapCooldown(l)}s CD)`,
+ },
+ {
+ id: 'gascloud',
+ name: 'Gaswolke',
+ type: 'active',
+ maxLevel: 5,
+ describe: (l) => `${gasDamage(l)} DPS · Radius ${gasRadius(l)} · ${gasCooldown(l)}s CD`,
+ },
];
export function getSkill(id: string): SkillDef {
@@ -126,6 +158,45 @@ export function swordRange(level: number): number {
export function swordDamage(level: number): number {
return 50 + 25 * level;
}
+export function auraRadius(level: number): number {
+ return [3, 3.4, 3.8, 4.2, 4.6][Math.min(level, 5) - 1] ?? 3;
+}
+export function auraDamage(level: number): number {
+ return [8, 12, 16, 20, 26][Math.min(level, 5) - 1] ?? 8;
+}
+export function auraPush(level: number): number {
+ return [0.8, 0.9, 1.0, 1.1, 1.2][Math.min(level, 5) - 1] ?? 0.8;
+}
+export function boomerangDamage(level: number): number {
+ return [12, 18, 24, 32, 40][Math.min(level, 5) - 1] ?? 12;
+}
+export function boomerangCount(level: number): number {
+ return [1, 1, 2, 2, 3][Math.min(level, 5) - 1] ?? 1;
+}
+export function trapDamage(level: number): number {
+ return [25, 35, 45, 55, 70][Math.min(level, 5) - 1] ?? 25;
+}
+export function trapHold(level: number): number {
+ return [1.5, 1.75, 2, 2.25, 2.5][Math.min(level, 5) - 1] ?? 1.5;
+}
+export function trapMax(level: number): number {
+ return [3, 3, 4, 4, 5][Math.min(level, 5) - 1] ?? 3;
+}
+export function trapCooldown(level: number): number {
+ return [8, 7, 6.5, 6, 5][Math.min(level, 5) - 1] ?? 8;
+}
+export function gasDamage(level: number): number {
+ return [8, 12, 16, 20, 25][Math.min(level, 5) - 1] ?? 8;
+}
+export function gasRadius(level: number): number {
+ return [3.5, 4, 4.5, 5, 5.5][Math.min(level, 5) - 1] ?? 3.5;
+}
+export function gasDuration(level: number): number {
+ return [4, 4.5, 5, 5.5, 6][Math.min(level, 5) - 1] ?? 4;
+}
+export function gasCooldown(level: number): number {
+ return [10, 9, 8.5, 8, 7][Math.min(level, 5) - 1] ?? 10;
+}
function mulberry32(seed: number): () => number {
let a = seed >>> 0;
diff --git a/web/src/Trap.ts b/web/src/Trap.ts
new file mode 100644
index 0000000..afbc9c7
--- /dev/null
+++ b/web/src/Trap.ts
@@ -0,0 +1,108 @@
+import * as THREE from 'three';
+import type { Game } from './Game';
+import type { Enemy } from './Enemy';
+import { trapDamage, trapHold } from './Skills';
+
+const TRIGGER_RADIUS = 1.1;
+const LIFETIME = 20;
+const TICK = 0.5;
+
+export class Trap {
+ body: THREE.Group;
+ private level: number;
+ private life = LIFETIME;
+ private triggered = false;
+ private tickTimer = 0;
+ private target: Enemy | null = null;
+ private jaws: THREE.Mesh[];
+ private pulse = 0;
+
+ constructor(position: THREE.Vector3, level: number) {
+ this.level = level;
+ this.body = new THREE.Group();
+ this.body.position.copy(position);
+ this.body.position.y = 0;
+
+ const baseGeom = new THREE.CylinderGeometry(0.45, 0.55, 0.12, 24);
+ const baseMat = new THREE.MeshToonMaterial({ color: 0x556066 });
+ const base = new THREE.Mesh(baseGeom, baseMat);
+ base.position.y = 0.06;
+ base.castShadow = true;
+ this.body.add(base);
+
+ const ringGeom = new THREE.TorusGeometry(0.35, 0.05, 8, 24);
+ const ringMat = new THREE.MeshBasicMaterial({
+ color: 0xffcc33,
+ transparent: true,
+ opacity: 0.8,
+ });
+ const ring = new THREE.Mesh(ringGeom, ringMat);
+ ring.rotation.x = -Math.PI / 2;
+ ring.position.y = 0.13;
+ this.body.add(ring);
+
+ this.jaws = [];
+ for (const side of [-1, 1]) {
+ const jaw = new THREE.Mesh(
+ new THREE.BoxGeometry(0.3, 0.06, 0.55),
+ new THREE.MeshToonMaterial({ color: 0x9aa7b0 })
+ );
+ jaw.position.set(side * 0.2, 0.14, 0);
+ jaw.castShadow = true;
+ this.body.add(jaw);
+ this.jaws.push(jaw);
+ }
+ }
+
+ update(dt: number, game: Game): boolean {
+ if (!this.triggered) {
+ this.life -= dt;
+ if (this.life <= 0) return false;
+
+ for (const enemy of game.enemies) {
+ if (enemy.dead) continue;
+ const dist = enemy.body.position.distanceTo(this.body.position);
+ if (dist < TRIGGER_RADIUS) {
+ this.triggered = true;
+ this.target = enemy;
+ enemy.rootTime = trapHold(this.level);
+ this.pulse = 1;
+ break;
+ }
+ }
+ return true;
+ }
+
+ this.tickTimer -= dt;
+ if (this.pulse > 0) {
+ this.pulse -= dt * 4;
+ for (const jaw of this.jaws) {
+ jaw.scale.setScalar(1 + Math.max(0, this.pulse) * 0.6);
+ }
+ }
+
+ if (!this.target || this.target.dead || this.target.rootTime <= 0) {
+ return false;
+ }
+ if (this.tickTimer <= 0) {
+ this.tickTimer = TICK;
+ this.target.takeDamage(
+ (trapDamage(this.level) / trapHold(this.level)) * TICK,
+ game,
+ false,
+ this.body.position
+ );
+ }
+ return true;
+ }
+
+ dispose() {
+ this.body.removeFromParent();
+ for (const child of [...this.body.children]) {
+ if (child instanceof THREE.Mesh) {
+ child.geometry.dispose();
+ (child.material as THREE.Material).dispose();
+ }
+ }
+ }
+}
diff --git a/web/src/Trapper.ts b/web/src/Trapper.ts
new file mode 100644
index 0000000..da5cdf5
--- /dev/null
+++ b/web/src/Trapper.ts
@@ -0,0 +1,38 @@
+import * as THREE from 'three';
+import { Weapon } from './Weapon';
+import { Player } from './Player';
+import { Trap } from './Trap';
+import { GasCloud } from './GasCloud';
+import { playSound } from './SoundManager';
+import { trapCooldown, trapMax, gasCooldown } from './Skills';
+
+export class Trapper extends Weapon {
+ skill1(groundPoint: THREE.Vector3, player: Player): void {
+ const level = player.game.skillSystem.getLevel('trap');
+ if (level <= 0) return;
+ if (this.cooldown1 > 0) return;
+ if (player.game.traps.length >= trapMax(level)) return;
+
+ this.COOLDOWN1_TIME = trapCooldown(level);
+ this.cooldown1 = this.COOLDOWN1_TIME;
+
+ const trap = new Trap(groundPoint.clone(), level);
+ player.game.scene.add(trap.body);
+ player.game.traps.push(trap);
+ playSound('spawn', 0.4);
+ }
+
+ skill2(groundPoint: THREE.Vector3, player: Player): void {
+ const level = player.game.skillSystem.getLevel('gascloud');
+ if (level <= 0) return;
+ if (this.cooldown2 > 0) return;
+
+ this.COOLDOWN2_TIME = gasCooldown(level);
+ this.cooldown2 = this.COOLDOWN2_TIME;
+
+ const cloud = new GasCloud(groundPoint.clone(), level);
+ player.game.scene.add(cloud.body);
+ player.game.gasClouds.push(cloud);
+ playSound('spawn', 0.4);
+ }
+}