diff --git a/web/index.html b/web/index.html index 1d8d229..1364339 100644 --- a/web/index.html +++ b/web/index.html @@ -76,6 +76,8 @@ #sword-cooldown { bottom: 48px; } #teleport-cooldown { bottom: 24px; } #lightning-cooldown { bottom: 96px; } + #trap-cooldown { bottom: 120px; } + #gas-cooldown { bottom: 144px; } #wave-bar-container { position: absolute; top: 16px; right: 16px; width: 250px; height: 16px; } @@ -99,7 +101,8 @@ #game-over-panel { position: absolute; top: 62%; left: 50%; transform: translate(-50%, -50%); display: flex; flex-direction: column; align-items: center; gap: 12px; - pointer-events: none; opacity: 0; transition: opacity 0.8s ease-in 1.5s; + pointer-events: none; opacity: 0; visibility: hidden; + transition: opacity 0.8s ease-in 1.5s; font-family: monospace; } #game-over-stats { @@ -180,7 +183,16 @@ position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 24px; background: rgba(0,0,0,0.7); opacity: 0; pointer-events: none; - transition: opacity 0.3s ease; font-family: monospace; + visibility: hidden; transition: opacity 0.3s ease; font-family: monospace; + } + #pause-overlay { + position: absolute; top: 0; left: 0; width: 100%; height: 100%; + display: flex; flex-direction: column; align-items: center; justify-content: center; + gap: 18px; background: rgba(0,0,0,0.6); opacity: 0; pointer-events: none; + visibility: hidden; transition: opacity 0.2s ease; font-family: monospace; + } + #pause-overlay h2 { + color: #fff; font-size: 48px; text-shadow: 3px 3px 6px #000; } #level-up h2 { color: #ffcc00; font-size: 44px; text-shadow: 3px 3px 6px #000; @@ -259,6 +271,16 @@
Chain Lightning
+ +
@@ -274,8 +296,7 @@
-

Level Up!

-
+

Level Up!

@@ -298,19 +319,26 @@
+
+

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); + } +}