From 6f207ba1a0caa546fda656ff52da322fb42ddec8 Mon Sep 17 00:00:00 2001 From: nico Date: Mon, 17 Aug 2026 16:38:58 +0200 Subject: [PATCH] Add skill system, level-ups, and new enemy types --- README.md | 13 +- web/index.html | 129 ++++++++++++- web/src/Bat.ts | 119 ++++++++++++ web/src/BeeSwarm.ts | 146 ++++++++++++++ web/src/Boss.ts | 32 ++++ web/src/Brute.ts | 105 ++++++++++ web/src/ChainLightning.ts | 157 +++++++++++++++ web/src/Crab.ts | 165 ++++++++++++++++ web/src/Enemy.ts | 72 ++++++- web/src/EnemyProjectile.ts | 76 ++++++++ web/src/Fireball.ts | 2 + web/src/Frog.ts | 285 +++++++++++++++++++++++++++ web/src/Game.ts | 382 ++++++++++++++++++++++++++++++++++--- web/src/Ghost.ts | 15 +- web/src/Player.ts | 103 ++++++++-- web/src/Skills.ts | 212 ++++++++++++++++++++ web/src/Slime.ts | 110 +++++++++++ web/src/Spider.ts | 2 +- web/src/Staff.ts | 11 ++ web/src/Sword.ts | 13 +- web/src/SwordTrail.ts | 27 +-- web/src/Turret.ts | 96 ++++++++++ web/vite.config.ts | 3 + 23 files changed, 2192 insertions(+), 83 deletions(-) create mode 100644 web/src/Bat.ts create mode 100644 web/src/BeeSwarm.ts create mode 100644 web/src/Boss.ts create mode 100644 web/src/Brute.ts create mode 100644 web/src/ChainLightning.ts create mode 100644 web/src/Crab.ts create mode 100644 web/src/EnemyProjectile.ts create mode 100644 web/src/Frog.ts create mode 100644 web/src/Skills.ts create mode 100644 web/src/Slime.ts create mode 100644 web/src/Turret.ts diff --git a/README.md b/README.md index d9111b6..757d2b5 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,13 @@ Browser-Port des 2012er jMonkeyEngine-Spiels "Wackelpeter" (Blobtest). ## Übersicht 3D Arena-Survival-Spiel: Der Blob "Wackelpeter" kämpft mit Schwert und -Feuerball-Stab gegen Wellen von Spinnen. Bei 10% Kill-Chance droppt ein +Feuerball-Stab gegen Wellen von Monstern. Bei 10% Kill-Chance droppt ein heilendes Kreuz. Cel-Shading-Look, Wellen werden alle 10 Sekunden stärker. +Monster: Spinnen (ab Welle 1), Geister (ab 3), springende Frösche (ab 4), +Schleime (ab 5, teilen sich), Fledermäuse (ab 5), Pilz-Türme (ab 6), +Krabben (ab 7), Bienenschwärme (ab 7) und alle 5 Wellen ein Boss-Golem. + ## Restaurierung Das Original lag nur kompiliert vor (Blobtest.exe + JME3-Libs). Der Source @@ -45,8 +49,11 @@ npm run dev - Three.js + TypeScript + Vite - Steuerung: WASD bewegen, Space springen, Maus zielen, - Linksklick Feuerball, Rechtsklick Schwert, M3 Hände tauschen, - E Teleport, H Hitboxen (Debug) + Linksklick Schwert, Rechtsklick Feuerball (per Skill), + Q Kettenblitz (per Skill), E Teleport (per Skill), H Hitboxen (Debug) +- Roguelike-Skillsystem: Kills geben XP, bei Level-Up pausiert das Spiel + und bietet 3 Skill-Karten (neue Skills oder Upgrades). Seed (Startmenü) + steuert die Angebote, "Nochmal spielen" behält den Seed bei. ## Verzeichnisse diff --git a/web/index.html b/web/index.html index 3883f4e..3cf9b10 100644 --- a/web/index.html +++ b/web/index.html @@ -7,7 +7,10 @@
-
100%
+
100
+
+
+
Wave 0
Kills: 0
+
Level 0
+
+
+
+
XP: 0/20
+
+
-
+ -
+ +
@@ -146,18 +240,41 @@
+
+

Level Up!

+
+
+
+
+
[1] auswählen
+
+
+
+
+
[2] auswählen
+
+
+
+
+
[3] auswählen
+
+
+ +
+
- WASD: Move · Space: Jump · Mouse: Aim · Left Click: Fireball · Right Click: Sword · M3: Swap hands · E: Teleport + WASD: Move · Space: Jump · Mouse: Aim · Left Click: Sword · Right Click: Fireball · Q: Lightning · E: Teleport
diff --git a/web/src/Bat.ts b/web/src/Bat.ts new file mode 100644 index 0000000..5019e51 --- /dev/null +++ b/web/src/Bat.ts @@ -0,0 +1,119 @@ +import * as THREE from 'three'; +import type { Game } from './Game'; +import { Enemy } from './Enemy'; + +export class Bat extends Enemy { + private model: THREE.Group; + private wingL: THREE.Mesh; + private wingR: THREE.Mesh; + private flapTime = Math.random() * Math.PI * 2; + private flyTime = Math.random() * Math.PI * 2; + + constructor() { + super(); + this.health = 25; + this.speed = 8; + this.xp = 10; + + this.model = new THREE.Group(); + this.fadeOutModel = this.model; + this.body.add(this.model); + + const furMat = new THREE.MeshToonMaterial({ color: 0x5a4568 }); + + // Body + const bodyMesh = new THREE.Mesh(new THREE.SphereGeometry(0.3, 14, 12), furMat); + bodyMesh.scale.set(1, 0.9, 1.3); + bodyMesh.castShadow = true; + this.registerFadeMesh(bodyMesh); + this.model.add(bodyMesh); + + // Head with ears (face toward the player: -Z) + const head = new THREE.Mesh(new THREE.SphereGeometry(0.18, 12, 10), furMat); + head.position.set(0, 0.15, -0.3); + this.registerFadeMesh(head); + this.model.add(head); + + for (const side of [-1, 1]) { + const ear = new THREE.Mesh(new THREE.ConeGeometry(0.07, 0.18, 6), furMat); + ear.position.set(side * 0.12, 0.32, -0.25); + this.registerFadeMesh(ear); + this.model.add(ear); + } + + // Eyes + const eyeMat = new THREE.MeshToonMaterial({ color: 0xff2222 }); + for (const side of [-1, 1]) { + const eye = new THREE.Mesh(new THREE.SphereGeometry(0.035, 8, 8), eyeMat); + eye.position.set(side * 0.07, 0.16, -0.42); + this.registerFadeMesh(eye); + this.model.add(eye); + } + + // Wings (hinged at the body, flap via rotation.z) + const wingGeom = new THREE.PlaneGeometry(0.75, 0.5, 1, 4); + wingGeom.translate(0.3, 0, 0); + const wingMat = new THREE.MeshToonMaterial({ + color: 0x443355, + side: THREE.DoubleSide, + transparent: true, + opacity: 0.9, + }); + this.wingL = new THREE.Mesh(wingGeom, wingMat); + this.wingR = new THREE.Mesh(wingGeom.clone(), wingMat); + this.wingL.position.set(-0.3, 0.05, 0); + this.wingR.position.set(0.3, 0.05, 0); + this.registerFadeMesh(this.wingL); + this.registerFadeMesh(this.wingR); + this.model.add(this.wingL, this.wingR); + } + + playAnim() { + // Procedural visuals only + } + + updateAnimation(dt: number) { + this.updateFadeDeath(dt, { duration: 0.7, sink: 3 }); + } + + update(dt: number, game: Game) { + this.flapTime += dt * 14; + this.flyTime += dt * 3; + + const flap = Math.sin(this.flapTime) * 0.9; + this.wingL.rotation.z = -1.1 - flap; + this.wingR.rotation.z = 1.1 + flap; + + // Bob up and down while swooping at the player + this.body.position.y = 1.0 + Math.sin(this.flyTime) * 0.5; + + const toPlayer = new THREE.Vector3() + .subVectors(game.player.body.position, this.body.position); + toPlayer.y = 0; + const dist = toPlayer.length(); + if (dist > 0.1) { + const dir = toPlayer.normalize(); + this.body.lookAt( + this.body.position.x + dir.x, + this.body.position.y, + this.body.position.z + dir.z + ); + // Slight zigzag + const perp = new THREE.Vector3(-dir.z, 0, dir.x) + .multiplyScalar(Math.sin(this.flyTime * 2.2) * 0.6); + + let moveDir = dir.clone().multiplyScalar(this.speed).add(perp); + if (this.knockback > 0) { + moveDir = dir.multiplyScalar(-this.speed); + this.knockback -= dt; + } + this.body.position.x += moveDir.x * dt; + this.body.position.z += moveDir.z * dt; + game.clampToArena(this.body.position); + } + } + + protected onDeath(_game: Game) { + this.beginFadeDeath(); + } +} diff --git a/web/src/BeeSwarm.ts b/web/src/BeeSwarm.ts new file mode 100644 index 0000000..6375438 --- /dev/null +++ b/web/src/BeeSwarm.ts @@ -0,0 +1,146 @@ +import * as THREE from 'three'; +import type { Game } from './Game'; +import { Enemy } from './Enemy'; + +const BEE_COUNT = 4; + +interface SwarmBee { + mesh: THREE.Group; + wingL: THREE.Mesh; + wingR: THREE.Mesh; + angle: number; + radius: number; + orbitSpeed: number; + scatter: THREE.Vector3; +} + +export class BeeSwarm extends Enemy { + private model: THREE.Group; + private bees: SwarmBee[] = []; + private time = Math.random() * Math.PI * 2; + + constructor() { + super(); + this.health = 50; + this.speed = 6.5; + this.xp = 15; + + this.model = new THREE.Group(); + this.body.add(this.model); + + const bodyGeom = new THREE.CapsuleGeometry(0.11, 0.16, 4, 10); + const bodyMat = new THREE.MeshToonMaterial({ color: 0xffcc33 }); + const stripeMat = new THREE.MeshToonMaterial({ color: 0x222222 }); + const wingMat = new THREE.MeshToonMaterial({ + color: 0xffffff, + transparent: true, + opacity: 0.7, + side: THREE.DoubleSide, + }); + + for (let i = 0; i < BEE_COUNT; i++) { + const bee = new THREE.Group(); + + const body = new THREE.Mesh(bodyGeom, bodyMat); + body.rotation.x = Math.PI / 2; + bee.add(body); + + for (const stripeZ of [-0.1, 0.1]) { + const stripe = new THREE.Mesh(new THREE.CylinderGeometry(0.115, 0.115, 0.05, 10), stripeMat); + stripe.rotation.x = Math.PI / 2; + stripe.position.z = stripeZ; + bee.add(stripe); + } + + const wingL = new THREE.Mesh(new THREE.PlaneGeometry(0.14, 0.24), wingMat); + wingL.position.set(0.12, 0.08, -0.02); + wingL.rotation.y = Math.PI / 2; + bee.add(wingL); + const wingR = wingL.clone(); + wingR.position.x = -0.12; + bee.add(wingR); + + bee.scale.setScalar(0.9); + this.model.add(bee); + this.bees.push({ + mesh: bee, + wingL, + wingR, + angle: (i / BEE_COUNT) * Math.PI * 2, + radius: 0.4 + (i % 2) * 0.15, + orbitSpeed: 2 + (i % 3) * 0.4, + scatter: new THREE.Vector3( + Math.cos((i / BEE_COUNT) * Math.PI * 2), + 0.5 + Math.random(), + Math.sin((i / BEE_COUNT) * Math.PI * 2) + ).multiplyScalar(3), + }); + } + + this.fadeOutModel = this.model; + } + + playAnim() { + // Procedural visuals only + } + + updateAnimation(dt: number) { + if (this.dead) { + // Scatter the bees outward while fading + this.time += dt; + for (const bee of this.bees) { + bee.mesh.position.x += bee.scatter.x * dt; + bee.mesh.position.y += bee.scatter.y * dt; + bee.mesh.position.z += bee.scatter.z * dt; + } + this.updateFadeDeath(dt, { duration: 1.0, shrink: 0.4 }); + } + } + + update(dt: number, game: Game) { + this.time += dt; + this.body.position.y = 1.2 + Math.sin(this.time * 2) * 0.2; + + // Orbit the individual bees around the cluster center + for (const bee of this.bees) { + bee.angle += bee.orbitSpeed * dt; + bee.mesh.position.set( + Math.cos(bee.angle) * bee.radius, + Math.sin(bee.angle * 2.3) * 0.25, + Math.sin(bee.angle) * bee.radius + ); + bee.mesh.rotation.y = -bee.angle; + const flap = Math.sin(this.time * 40 + bee.angle) * 0.9; + bee.wingL.rotation.x = flap; + bee.wingR.rotation.x = -flap; + } + + const toPlayer = new THREE.Vector3() + .subVectors(game.player.body.position, this.body.position); + toPlayer.y = 0; + const dist = toPlayer.length(); + if (dist > 0.1) { + const dir = toPlayer.normalize(); + this.body.lookAt( + this.body.position.x + dir.x, + this.body.position.y, + this.body.position.z + dir.z + ); + const perp = new THREE.Vector3(-dir.z, 0, dir.x) + .multiplyScalar(Math.sin(this.time * 4) * 0.8); + + let moveDir = dir.clone().multiplyScalar(this.speed).add(perp); + if (this.knockback > 0) { + moveDir = dir.multiplyScalar(-this.speed); + this.knockback -= dt; + } + this.body.position.x += moveDir.x * dt; + this.body.position.z += moveDir.z * dt; + game.clampToArena(this.body.position); + } + } + + protected onDeath(_game: Game) { + this.beginFadeDeath(); + } +} diff --git a/web/src/Boss.ts b/web/src/Boss.ts new file mode 100644 index 0000000..684b3c4 --- /dev/null +++ b/web/src/Boss.ts @@ -0,0 +1,32 @@ +import * as THREE from 'three'; +import type { Game } from './Game'; +import { Brute } from './Brute'; + +export class Boss extends Brute { + constructor() { + super(); + this.health = 800; + this.speed = 3; + this.xp = 80; + this.contactDps = 50; + this.contactRadius = 3.4; + this.guaranteedDrop = true; + + this.model.scale.setScalar(2.1); + + // Crown + const crownMat = new THREE.MeshToonMaterial({ color: 0xffcc33 }); + const crown = new THREE.Mesh( + new THREE.ConeGeometry(0.3, 0.4, 8, 1, true), + crownMat + ); + crown.position.y = 2.25; + crown.castShadow = true; + this.registerFadeMesh(crown); + this.model.add(crown); + } + + protected onDeath(_game: Game) { + this.beginFadeDeath(); + } +} diff --git a/web/src/Brute.ts b/web/src/Brute.ts new file mode 100644 index 0000000..a61012c --- /dev/null +++ b/web/src/Brute.ts @@ -0,0 +1,105 @@ +import * as THREE from 'three'; +import type { Game } from './Game'; +import { Enemy } from './Enemy'; + +export class Brute extends Enemy { + protected model: THREE.Group; + private stepTime = 0; + + constructor() { + super(); + this.health = 300; + this.speed = 3.5; + this.xp = 40; + this.contactDps = 40; + this.contactRadius = 2.4; + + this.model = new THREE.Group(); + this.fadeOutModel = this.model; + this.body.add(this.model); + + const rockMat = new THREE.MeshToonMaterial({ color: 0x6a6a78 }); + const darkMat = new THREE.MeshToonMaterial({ color: 0x3c3c46 }); + + // Torso + const torso = new THREE.Mesh(new THREE.BoxGeometry(1.3, 1.2, 0.95), rockMat); + torso.position.y = 0.95; + torso.castShadow = true; + this.registerFadeMesh(torso); + this.model.add(torso); + + // Shoulders + for (const side of [-1, 1]) { + const shoulder = new THREE.Mesh(new THREE.SphereGeometry(0.38, 12, 10), rockMat); + shoulder.position.set(side * 0.8, 1.35, 0); + shoulder.castShadow = true; + this.registerFadeMesh(shoulder); + this.model.add(shoulder); + } + + // Arms + for (const side of [-1, 1]) { + const arm = new THREE.Mesh(new THREE.CylinderGeometry(0.22, 0.28, 0.9, 8), rockMat); + arm.position.set(side * 0.85, 0.55, 0); + arm.castShadow = true; + this.registerFadeMesh(arm); + this.model.add(arm); + } + + // Head + const head = new THREE.Mesh(new THREE.BoxGeometry(0.6, 0.55, 0.6), darkMat); + head.position.y = 1.85; + head.castShadow = true; + this.registerFadeMesh(head); + this.model.add(head); + + // Glowing eyes (face toward the player: -Z) + const eyeMat = new THREE.MeshBasicMaterial({ color: 0xff6622 }); + for (const side of [-1, 1]) { + const eye = new THREE.Mesh(new THREE.BoxGeometry(0.14, 0.1, 0.05), eyeMat); + eye.position.set(side * 0.16, 1.9, -0.32); + this.registerFadeMesh(eye); + this.model.add(eye); + } + } + + playAnim() { + // Procedural visuals only + } + + updateAnimation(dt: number) { + this.updateFadeDeath(dt, { duration: 1.2, sink: 0.5 }); + } + + update(dt: number, game: Game) { + this.stepTime += dt; + + // Heavy footsteps + this.body.position.y = 0.15 + Math.abs(Math.sin(this.stepTime * 3)) * 0.07; + + const toPlayer = new THREE.Vector3() + .subVectors(game.player.body.position, this.body.position); + toPlayer.y = 0; + const dist = toPlayer.length(); + if (dist > 0.1) { + const dir = toPlayer.normalize(); + this.body.lookAt( + this.body.position.x + dir.x, + this.body.position.y, + this.body.position.z + dir.z + ); + let moveDir = dir.multiplyScalar(this.speed); + if (this.knockback > 0) { + moveDir = dir.multiplyScalar(-this.speed); + this.knockback -= dt; + } + this.body.position.x += moveDir.x * dt; + this.body.position.z += moveDir.z * dt; + game.clampToArena(this.body.position); + } + } + + protected onDeath(_game: Game) { + this.beginFadeDeath(); + } +} diff --git a/web/src/ChainLightning.ts b/web/src/ChainLightning.ts new file mode 100644 index 0000000..588dc27 --- /dev/null +++ b/web/src/ChainLightning.ts @@ -0,0 +1,157 @@ +import * as THREE from 'three'; +import { Weapon } from './Weapon'; +import { Player } from './Player'; +import { lightningDamage, lightningJumps, lightningCooldown } from './Skills'; + +const CHAIN_FIRST_RANGE = 14; +const CHAIN_JUMP_RANGE = 8; +const FLASH_TIME = 0.35; + +export class ChainLightning extends Weapon { + constructor() { + super(); + this.COOLDOWN1_TIME = lightningCooldown(1); + } + + skill1(_groundPoint: THREE.Vector3, player: Player): void { + const skills = player.game.skillSystem; + const level = skills.getLevel('chainlightning'); + if (level <= 0) return; + if (this.cooldown1 > 0) return; + + this.COOLDOWN1_TIME = lightningCooldown(level); + this.cooldown1 = this.COOLDOWN1_TIME; + + const game = player.game; + const damage = lightningDamage(level); + const maxJumps = lightningJumps(level); + + // Chain through enemies, always the closest un-hit one + const start = player.body.position.clone(); + start.y += 0.8; + const chainPoints: THREE.Vector3[] = [start]; + const hit: Set = new Set(); + let from = player.body.position.clone(); + + for (let i = 0; i < maxJumps; i++) { + let best: import('./Enemy').Enemy | null = null; + let bestDist = i === 0 ? CHAIN_FIRST_RANGE : CHAIN_JUMP_RANGE; + for (const enemy of game.enemies) { + if (enemy.dead || hit.has(enemy)) continue; + const dist = enemy.body.position.distanceTo(from); + if (dist < bestDist) { + bestDist = dist; + best = enemy; + } + } + if (!best) break; + + hit.add(best); + const fromPoint = from.clone(); + best.takeDamage(damage, game, true, fromPoint); + const point = best.body.position.clone(); + point.y += 0.8; + chainPoints.push(point); + from = best.body.position; + } + + if (chainPoints.length < 2) return; + + this.spawnVisual(game.scene, chainPoints, hit); + } + + skill2(): void { + // Not implemented + } + + private spawnVisual( + scene: THREE.Scene, + points: THREE.Vector3[], + hit: Set + ) { + const group = new THREE.Group(); + scene.add(group); + + // Segments between chain points + for (let i = 0; i < points.length - 1; i++) { + const lineGeom = new THREE.BufferGeometry().setFromPoints([ + points[i], + points[i + 1], + ]); + const lineMat = new THREE.LineBasicMaterial({ + color: 0x88ccff, + transparent: true, + opacity: 0.9, + blending: THREE.AdditiveBlending, + depthWrite: false, + }); + const line = new THREE.Line(lineGeom, lineMat); + group.add(line); + + // Jagged inner bolt + const mid = new THREE.Vector3() + .addVectors(points[i], points[i + 1]) + .multiplyScalar(0.5); + mid.x += (Math.random() - 0.5) * 1.2; + mid.y += (Math.random() - 0.5) * 1.2; + mid.z += (Math.random() - 0.5) * 1.2; + const boltGeom = new THREE.BufferGeometry().setFromPoints([ + points[i], mid, points[i + 1], + ]); + const boltMat = new THREE.LineBasicMaterial({ + color: 0xffffff, + transparent: true, + opacity: 0.9, + blending: THREE.AdditiveBlending, + depthWrite: false, + }); + const bolt = new THREE.Line(boltGeom, boltMat); + group.add(bolt); + } + + // Flash spheres on hit enemies + const flashes: THREE.Mesh[] = []; + for (const enemy of hit) { + const flashGeom = new THREE.SphereGeometry(0.7, 12, 12); + const flashMat = new THREE.MeshBasicMaterial({ + color: 0x88ccff, + transparent: true, + opacity: 0.7, + blending: THREE.AdditiveBlending, + depthWrite: false, + }); + const flash = new THREE.Mesh(flashGeom, flashMat); + flash.position.copy(enemy.body.position); + flash.position.y += 0.8; + group.add(flash); + flashes.push(flash); + } + + const startTime = performance.now(); + const update = () => { + const elapsed = (performance.now() - startTime) / 1000; + if (elapsed > FLASH_TIME) { + group.removeFromParent(); + for (const child of [...group.children]) { + if (child instanceof THREE.Line || child instanceof THREE.Mesh) { + child.geometry.dispose(); + (child.material as THREE.Material).dispose(); + } + } + return; + } + const fade = 1 - elapsed / FLASH_TIME; + for (const child of group.children) { + if (child instanceof THREE.Line || child instanceof THREE.Mesh) { + (child.material as THREE.Material & { opacity: number }).opacity = fade; + } + } + for (const flash of flashes) { + const s = 0.7 + elapsed * 3; + flash.scale.set(s, s, s); + } + requestAnimationFrame(update); + }; + update(); + } +} diff --git a/web/src/Crab.ts b/web/src/Crab.ts new file mode 100644 index 0000000..3bbe884 --- /dev/null +++ b/web/src/Crab.ts @@ -0,0 +1,165 @@ +import * as THREE from 'three'; +import type { Game } from './Game'; +import { Enemy } from './Enemy'; + +export class Crab extends Enemy { + private model: THREE.Group; + private clawL: THREE.Mesh; + private clawR: THREE.Mesh; + private time = Math.random() * Math.PI * 2; + private side = Math.random() < 0.5 ? 1 : -1; + + constructor() { + super(); + this.health = 90; + this.speed = 4.5; + this.xp = 20; + this.contactRadius = 2.2; + + this.model = new THREE.Group(); + this.fadeOutModel = this.model; + this.body.add(this.model); + + const shellMat = new THREE.MeshToonMaterial({ color: 0xdd5533 }); + const darkMat = new THREE.MeshToonMaterial({ color: 0x993322 }); + + // Shell body + const shell = new THREE.Mesh(new THREE.SphereGeometry(0.55, 18, 14), shellMat); + shell.scale.set(1, 0.55, 0.85); + shell.position.y = 0.35; + shell.castShadow = true; + this.registerFadeMesh(shell); + this.model.add(shell); + + // Shell spots + for (const [dx, dz] of [[0, -0.15], [0.25, 0.1], [-0.25, 0.1]] as const) { + const spot = new THREE.Mesh(new THREE.SphereGeometry(0.08, 8, 8), darkMat); + spot.scale.set(1, 0.4, 1); + spot.position.set(dx, 0.62, dz); + this.registerFadeMesh(spot); + this.model.add(spot); + } + + // Eye stalks (on the armored front side, -Z) + const stalkMat = new THREE.MeshToonMaterial({ color: 0xdd5533 }); + const eyeMat = new THREE.MeshToonMaterial({ color: 0x111122 }); + for (const side of [-1, 1]) { + const stalk = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.035, 0.22, 6), stalkMat); + stalk.position.set(side * 0.22, 0.55, -0.42); + this.registerFadeMesh(stalk); + this.model.add(stalk); + const eye = new THREE.Mesh(new THREE.SphereGeometry(0.09, 10, 8), eyeMat); + eye.position.set(side * 0.22, 0.68, -0.42); + this.registerFadeMesh(eye); + this.model.add(eye); + } + + // Legs + const legGeom = new THREE.CapsuleGeometry(0.05, 0.25, 4, 8); + for (const side of [-1, 1]) { + for (const legZ of [-0.3, 0.3]) { + const leg = new THREE.Mesh(legGeom, shellMat); + leg.position.set(side * 0.45, 0.15, legZ); + leg.rotation.z = side * 0.6; + this.registerFadeMesh(leg); + this.model.add(leg); + } + } + + // Big claws (front, open/close) + const clawGeom = new THREE.SphereGeometry(0.22, 12, 10); + this.clawL = new THREE.Mesh(clawGeom, shellMat); + this.clawR = new THREE.Mesh(clawGeom, shellMat); + this.clawL.position.set(-0.45, 0.45, -0.5); + this.clawR.position.set(0.45, 0.45, -0.5); + this.clawL.scale.set(1.2, 0.7, 1.4); + this.clawR.scale.set(1.2, 0.7, 1.4); + this.clawL.castShadow = true; + this.clawR.castShadow = true; + this.registerFadeMesh(this.clawL); + this.registerFadeMesh(this.clawR); + this.model.add(this.clawL, this.clawR); + } + + playAnim() { + // Procedural visuals only + } + + updateAnimation(dt: number) { + this.updateFadeDeath(dt, { duration: 0.8, sink: 0.4 }); + } + + update(dt: number, game: Game) { + this.time += dt; + + // Claw pinch + const pinch = 1 + Math.sin(this.time * 4) * 0.35; + this.clawL.scale.set(1.2, 0.7 * pinch, 1.4); + this.clawR.scale.set(1.2, 0.7 * pinch, 1.4); + + const toPlayer = new THREE.Vector3() + .subVectors(game.player.body.position, this.body.position); + toPlayer.y = 0; + const dist = toPlayer.length(); + + // Face the player + if (dist > 0.1) { + this.body.lookAt( + this.body.position.x + toPlayer.x, + this.body.position.y, + this.body.position.z + toPlayer.z + ); + } + + // Strafe sideways around the player + if (dist > 3) { + const perp = new THREE.Vector3(-toPlayer.z, 0, toPlayer.x).normalize(); + if (this.knockback > 0) { + this.knockback -= dt; + const away = toPlayer.clone().normalize().multiplyScalar(-this.speed); + this.body.position.x += away.x * dt; + this.body.position.z += away.z * dt; + } else { + // Flip side occasionally to circle + if (Math.random() < dt * 0.3) this.side *= -1; + this.body.position.x += perp.x * this.side * this.speed * dt; + this.body.position.z += perp.z * this.side * this.speed * dt; + } + game.clampToArena(this.body.position); + } else if (dist > 0.1) { + const dir = toPlayer.normalize(); + if (this.knockback > 0) { + this.knockback -= dt; + this.body.position.x -= dir.x * this.speed * dt; + this.body.position.z -= dir.z * this.speed * dt; + } + } + } + + takeDamage( + damage: number, + game: Game, + withKnockback = true, + sourcePos?: THREE.Vector3 + ) { + // Armored from the front: reduce damage coming from the facing side + if (sourcePos) { + const front = new THREE.Vector3(0, 0, -1) + .applyQuaternion(this.body.quaternion); + front.y = 0; + front.normalize(); + const incoming = new THREE.Vector3() + .subVectors(sourcePos, this.body.position); + incoming.y = 0; + incoming.normalize(); + if (front.dot(incoming) > 0.3) { + damage *= 0.4; + } + } + super.takeDamage(damage, game, withKnockback, sourcePos); + } + + protected onDeath(_game: Game) { + this.beginFadeDeath(); + } +} diff --git a/web/src/Enemy.ts b/web/src/Enemy.ts index 88b5dac..7f69ac7 100644 --- a/web/src/Enemy.ts +++ b/web/src/Enemy.ts @@ -9,10 +9,19 @@ export abstract class Enemy { knockback = 0; health = 100; speed = 6; + xp = 10; + contactDps = 25; + contactRadius = 2; + guaranteedDrop = false; + grace = 0; mixer: THREE.AnimationMixer | null = null; protected clips: Map = new Map(); protected currentAnim = ''; protected stopWalkSound: (() => void) | null = null; + protected fadeOutModel: THREE.Group | null = null; + protected fadeMaterials: THREE.Material[] = []; + private dying = false; + private dieTime = 0; constructor() { this.body = new THREE.Group(); @@ -22,16 +31,67 @@ export abstract class Enemy { abstract updateAnimation(dt: number): void; abstract update(dt: number, game: Game): void; - protected onDeath() { - // Subclasses override for model-specific death visuals + canBeSlashHit(): boolean { + return true; } - takeDamage(damage: number, game: Game) { - if (this.dead) return; + protected onDeath(game: Game) { + // Subclasses override for model-specific death behavior + } + + protected registerFadeMesh(mesh: THREE.Mesh) { + const mat = mesh.material as THREE.Material & { + transparent: boolean; + opacity: number; + }; + mat.transparent = true; + this.fadeMaterials.push(mat); + } + + protected beginFadeDeath() { + this.dying = true; + this.dieTime = 0; + } + + protected updateFadeDeath( + dt: number, + opts?: { duration?: number; shrink?: number; sink?: number } + ): boolean { + if (!this.dying) return false; + const duration = opts?.duration ?? 0.9; + this.dieTime += dt; + const t = Math.min(this.dieTime / duration, 1); + const scale = Math.max(0.05, 1 - t * (opts?.shrink ?? 0.95)); + if (this.fadeOutModel) { + this.fadeOutModel.scale.set(scale, scale, scale); + } else { + this.body.scale.set(scale, scale, scale); + } + const sink = opts?.sink ?? 0; + if (sink > 0) { + this.body.position.y = Math.max(-1, this.body.position.y - sink * dt); + } + const opacity = 1 - t; + for (const mat of this.fadeMaterials) { + (mat as THREE.Material & { opacity: number }).opacity = opacity; + } + return true; + } + + takeDamage( + damage: number, + game: Game, + withKnockback = true, + sourcePos?: THREE.Vector3 + ) { + if (this.dead || this.grace > 0) return; this.health -= damage; - this.knockback = 0.5; + if (withKnockback) { + this.knockback = 0.5; + } playSound('damage', 0.7); + game.onDamageDealt(damage); if (this.health <= 0) { this.die(game); @@ -41,7 +101,7 @@ export abstract class Enemy { private die(game: Game) { if (this.dead) return; this.dead = true; - this.onDeath(); + this.onDeath(game); if (this.stopWalkSound) { this.stopWalkSound(); this.stopWalkSound = null; diff --git a/web/src/EnemyProjectile.ts b/web/src/EnemyProjectile.ts new file mode 100644 index 0000000..779f2be --- /dev/null +++ b/web/src/EnemyProjectile.ts @@ -0,0 +1,76 @@ +import * as THREE from 'three'; +import type { Game } from './Game'; + +const SPEED = 12; +const HIT_RADIUS = 0.9; + +export class EnemyProjectile { + body: THREE.Group; + velocity: THREE.Vector3; + damage: number; + lifetime = 6; + dead = false; + + constructor(from: THREE.Vector3, target: THREE.Vector3, damage: number) { + this.damage = damage; + this.body = new THREE.Group(); + this.body.position.copy(from); + + this.velocity = new THREE.Vector3() + .subVectors(target, from) + .normalize() + .multiplyScalar(SPEED); + + // Glowing poison blob + const glowMat = new THREE.MeshBasicMaterial({ + color: 0x66ff44, + transparent: true, + opacity: 0.55, + blending: THREE.AdditiveBlending, + depthWrite: false, + }); + const glow = new THREE.Mesh(new THREE.SphereGeometry(0.4, 12, 12), glowMat); + this.body.add(glow); + + const coreMat = new THREE.MeshBasicMaterial({ + color: 0xaaff88, + blending: THREE.AdditiveBlending, + depthWrite: false, + }); + const core = new THREE.Mesh(new THREE.SphereGeometry(0.2, 10, 10), coreMat); + this.body.add(core); + } + + update(dt: number, game: Game): boolean { + this.lifetime -= dt; + this.body.position.x += this.velocity.x * dt; + this.body.position.y += this.velocity.y * dt; + this.body.position.z += this.velocity.z * dt; + + // Hit the player + if (game.player.health > 0) { + const dist = this.body.position.distanceTo(game.player.body.position); + if (dist < HIT_RADIUS) { + game.player.damage(this.damage); + this.dead = true; + return false; + } + } + + if (this.lifetime <= 0 || this.body.position.y < -1) { + this.dead = true; + return false; + } + 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/Fireball.ts b/web/src/Fireball.ts index 65ad070..9edaff6 100644 --- a/web/src/Fireball.ts +++ b/web/src/Fireball.ts @@ -12,6 +12,8 @@ export class Fireball { exploding = false; timer = 0; damageDone = false; + damage = 100; + explosionRadius = 8; private trailParticles: THREE.Points; private trailVelocities: Float32Array; private trailLives: Float32Array; diff --git a/web/src/Frog.ts b/web/src/Frog.ts new file mode 100644 index 0000000..bd64d4f --- /dev/null +++ b/web/src/Frog.ts @@ -0,0 +1,285 @@ +import * as THREE from 'three'; +import type { Game } from './Game'; +import { Enemy } from './Enemy'; +import { playSound } from './SoundManager'; + +const JUMP_DISTANCE = 6.5; +const JUMP_HEIGHT = 2.6; +const JUMP_DURATION = 0.65; +const CROUCH_TIME = 0.35; +const LAND_TIME = 0.3; +const PAUSE_TIME = 0.6; + +type FrogState = 'crouch' | 'leap' | 'land' | 'pause'; + +export class Frog extends Enemy { + private model: THREE.Group; + private bodyMesh: THREE.Mesh; + private eyeL: THREE.Mesh; + private eyeR: THREE.Mesh; + private legs: THREE.Mesh[] = []; + private shadow: THREE.Mesh; + private shadowMat: THREE.MeshBasicMaterial; + private whiteMat: THREE.MeshToonMaterial; + private state: FrogState = 'pause'; + private stateTimer = 0.4; + private jumpFrom = new THREE.Vector3(); + private jumpTo = new THREE.Vector3(); + private jumpProgress = 0; + private airborne = false; + private hopTime = 0; + + constructor() { + super(); + this.health = 35; + this.xp = 12; + + this.model = new THREE.Group(); + this.fadeOutModel = this.model; + this.body.add(this.model); + + const greenMat = new THREE.MeshToonMaterial({ color: 0x55cc33 }); + this.whiteMat = new THREE.MeshToonMaterial({ color: 0xf0fff0 }); + + // Squat body + this.bodyMesh = new THREE.Mesh(new THREE.SphereGeometry(0.55, 20, 16), greenMat); + this.bodyMesh.scale.set(1, 0.72, 1.1); + this.bodyMesh.position.y = 0.42; + this.bodyMesh.castShadow = true; + this.registerFadeMesh(this.bodyMesh); + this.model.add(this.bodyMesh); + + // Belly + const belly = new THREE.Mesh(new THREE.SphereGeometry(0.4, 16, 12), this.whiteMat); + belly.scale.set(0.85, 0.5, 0.85); + belly.position.set(0, 0.3, -0.42); + this.registerFadeMesh(belly); + this.model.add(belly); + + // Bulging eyes (face toward the player: -Z) + const eyeGeom = new THREE.SphereGeometry(0.17, 14, 12); + const pupilGeom = new THREE.SphereGeometry(0.08, 10, 8); + const pupilMat = new THREE.MeshToonMaterial({ color: 0x111122 }); + this.eyeL = new THREE.Mesh(eyeGeom, this.whiteMat); + this.eyeR = new THREE.Mesh(eyeGeom, this.whiteMat); + this.eyeL.position.set(-0.24, 0.72, -0.28); + this.eyeR.position.set(0.24, 0.72, -0.28); + this.eyeL.castShadow = true; + this.eyeR.castShadow = true; + this.registerFadeMesh(this.eyeL); + this.registerFadeMesh(this.eyeR); + this.model.add(this.eyeL, this.eyeR); + + for (const eye of [this.eyeL, this.eyeR]) { + const pupil = new THREE.Mesh(pupilGeom, pupilMat); + pupil.position.set(0, 0.02, -0.12); + this.registerFadeMesh(pupil); + eye.add(pupil); + } + + // Folded back legs + const legGeom = new THREE.SphereGeometry(0.16, 12, 10); + for (const side of [-1, 1]) { + const thigh = new THREE.Mesh(legGeom, greenMat); + thigh.scale.set(0.8, 1, 1.2); + thigh.position.set(side * 0.42, 0.25, 0.35); + thigh.rotation.y = side * 0.5; + thigh.castShadow = true; + this.registerFadeMesh(thigh); + this.model.add(thigh); + this.legs.push(thigh); + } + + // Blob shadow on the ground + this.shadowMat = new THREE.MeshBasicMaterial({ + color: 0x000000, + transparent: true, + opacity: 0.25, + }); + this.shadow = new THREE.Mesh(new THREE.CircleGeometry(0.5, 20), this.shadowMat); + this.shadow.rotation.x = -Math.PI / 2; + this.shadow.renderOrder = 998; + this.body.add(this.shadow); + } + + playAnim() { + // No rigged animations, visuals are procedural + } + + updateAnimation(dt: number) { + this.updateFadeDeath(dt, { duration: 0.8, sink: 0.6 }); + } + + update(dt: number, game: Game) { + this.hopTime += dt; + + // Knocked back: fall to the ground and slide away from the player + if (this.knockback > 0) { + this.knockback -= dt; + this.airborne = false; + this.state = 'pause'; + this.stateTimer = 0.3; + const away = new THREE.Vector3() + .subVectors(this.body.position, game.player.body.position); + away.y = 0; + away.normalize().multiplyScalar(this.speed); + this.body.position.x += away.x * dt; + this.body.position.z += away.z * dt; + this.body.position.y = 0.1; + } else { + switch (this.state) { + case 'pause': + this.body.position.y = 0.1; + this.stateTimer -= dt; + if (this.stateTimer <= 0) { + this.state = 'crouch'; + this.stateTimer = CROUCH_TIME; + } + break; + + case 'crouch': { + this.body.position.y = 0.1; + const target = game.player.body.position.clone(); + const toPlayer = target.sub(this.body.position); + toPlayer.y = 0; + if (toPlayer.length() > JUMP_DISTANCE) { + toPlayer.normalize().multiplyScalar(JUMP_DISTANCE); + } + this.jumpFrom.copy(this.body.position); + this.jumpTo.set( + this.body.position.x + toPlayer.x, + 0.1, + this.body.position.z + toPlayer.z + ); + this.body.lookAt(this.jumpTo.x, this.body.position.y, this.jumpTo.z); + this.stateTimer -= dt; + if (this.stateTimer <= 0) { + this.state = 'leap'; + this.jumpProgress = 0; + this.airborne = true; + } + break; + } + + case 'leap': { + this.jumpProgress += dt / JUMP_DURATION; + const t = Math.min(this.jumpProgress, 1); + this.body.position.x = THREE.MathUtils.lerp(this.jumpFrom.x, this.jumpTo.x, t); + this.body.position.z = THREE.MathUtils.lerp(this.jumpFrom.z, this.jumpTo.z, t); + this.body.position.y = 0.1 + Math.sin(t * Math.PI) * JUMP_HEIGHT; + if (this.jumpProgress >= 1) { + this.airborne = false; + this.state = 'land'; + this.stateTimer = LAND_TIME; + this.spawnDust(game.scene); + } + break; + } + + case 'land': + this.body.position.y = 0.1; + this.stateTimer -= dt; + if (this.stateTimer <= 0) { + this.state = 'pause'; + this.stateTimer = PAUSE_TIME + Math.random() * 0.5; + } + break; + } + } + + // Squash & stretch animation + let sy = 1; + let sxz = 1; + if (this.state === 'crouch') { + sy = 0.62; + sxz = 1.25; + } else if (this.state === 'leap') { + sy = 1.3; + sxz = 0.8; + } else if (this.state === 'land') { + sy = 0.7; + sxz = 1.25; + } + this.model.scale.set(sxz, sy, sxz); + this.eyeL.position.y = 0.72 * sy; + this.eyeR.position.y = 0.72 * sy; + + // Leg twitch + for (let i = 0; i < this.legs.length; i++) { + const side = i === 0 ? -1 : 1; + this.legs[i].rotation.z = side * Math.sin(this.hopTime * 8) * 0.1; + } + + // Shadow stays on the ground + this.shadow.position.y = 0.06 - this.body.position.y; + const shadowScale = 1 - (this.body.position.y - 0.1) / (JUMP_HEIGHT * 2); + this.shadowMat.opacity = 0.25 * Math.max(0.4, shadowScale); + + game.clampToArena(this.body.position); + } + + canBeSlashHit(): boolean { + return !this.airborne; + } + + protected onDeath(game: Game) { + playSound('spawn', 0.4); + this.beginFadeDeath(); + if (this.body.position.y > 0.15) { + this.body.position.y = 0.15; + } + void game; + } + + private spawnDust(scene: THREE.Scene) { + const count = 10; + const positions = new Float32Array(count * 3); + const velocities: THREE.Vector3[] = []; + const lives = new Float32Array(count).fill(0.4); + for (let i = 0; i < count; i++) { + positions[i * 3] = this.body.position.x; + positions[i * 3 + 1] = 0.15; + positions[i * 3 + 2] = this.body.position.z; + const angle = Math.random() * Math.PI * 2; + velocities.push(new THREE.Vector3( + Math.cos(angle) * 1.2, + Math.random() * 1.5, + Math.sin(angle) * 1.2 + )); + } + const geom = new THREE.BufferGeometry(); + geom.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + const mat = new THREE.PointsMaterial({ + color: 0xaa9988, + size: 0.15, + transparent: true, + depthWrite: false, + }); + const points = new THREE.Points(geom, mat); + points.renderOrder = 999; + scene.add(points); + + const startTime = performance.now(); + const update = () => { + const elapsed = (performance.now() - startTime) / 1000; + if (elapsed > 0.5) { + points.removeFromParent(); + geom.dispose(); + mat.dispose(); + return; + } + const arr = geom.getAttribute('position') as THREE.BufferAttribute; + for (let i = 0; i < count; i++) { + lives[i] -= 0.016; + if (lives[i] <= 0) continue; + arr.array[i * 3] += velocities[i].x * 0.016; + arr.array[i * 3 + 1] += velocities[i].y * 0.016; + arr.array[i * 3 + 2] += velocities[i].z * 0.016; + } + arr.needsUpdate = true; + mat.opacity = Math.max(0, 1 - elapsed / 0.5); + requestAnimationFrame(update); + }; + update(); + } +} diff --git a/web/src/Game.ts b/web/src/Game.ts index 66dd6a8..ee680f1 100644 --- a/web/src/Game.ts +++ b/web/src/Game.ts @@ -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 = new Set(); mouseButtons: Set = 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 { + const w = new Map(); + 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++; + } + } + } } } diff --git a/web/src/Ghost.ts b/web/src/Ghost.ts index c8cfc92..2e2f2d2 100644 --- a/web/src/Ghost.ts +++ b/web/src/Ghost.ts @@ -24,13 +24,14 @@ export class Ghost extends Enemy { private bobTime = Math.random() * Math.PI * 2; private visState: VisState = 'visible'; private visTimer = VISIBLE_TIME + Math.random(); - private dying = false; - private dieTime = 0; + private fadeDying = false; + private fadeDieTime = 0; constructor() { super(); this.health = 40; this.speed = 7.5; + this.xp = 15; this.model = new THREE.Group(); this.body.add(this.model); @@ -132,9 +133,9 @@ export class Ghost extends Enemy { } updateAnimation(dt: number) { - if (!this.dying) return; - this.dieTime += dt; - const t = Math.min(this.dieTime / 0.9, 1); + if (!this.fadeDying) return; + this.fadeDieTime += dt; + const t = Math.min(this.fadeDieTime / 0.9, 1); const scale = Math.max(0.05, 1 - t * 0.95); this.model.scale.set(scale, scale, scale); this.body.position.y = Math.max(-1, this.body.position.y - dt * 2.2); @@ -230,8 +231,8 @@ export class Ghost extends Enemy { for (const mesh of this.shadowCasters) mesh.castShadow = casts; } - protected onDeath() { - this.dying = true; + protected onDeath(_game: Game) { + this.fadeDying = true; } dispose() { diff --git a/web/src/Player.ts b/web/src/Player.ts index ea3192f..6766a48 100644 --- a/web/src/Player.ts +++ b/web/src/Player.ts @@ -1,19 +1,46 @@ import * as THREE from 'three'; import { Sword } from './Sword'; import { Staff } from './Staff'; +import { ChainLightning } from './ChainLightning'; import { loadGLB } from './MeshLoader'; import type { Game } from './Game'; +import type { SkillSystem } from './Skills'; + +export interface PlayerStats { + maxHealth: number; + regen: number; + lifesteal: number; + shield: number; + shieldRechargeDelay: number; + moveSpeed: number; + swordDamage: number; + swordRange: number; + thornDamage: number; +} export class Player { body: THREE.Group; leftHandWeapon: Staff; rightHandWeapon: Sword; + skillLightning: ChainLightning; velocity = new THREE.Vector3(); health = 100; - MAX_HEALTH = 100; game!: Game; jumpVelocity = 0; onGround = true; + shieldHp = 0; + shieldTimer = 0; + stats: PlayerStats = { + maxHealth: 100, + regen: 0, + lifesteal: 0, + shield: 0, + shieldRechargeDelay: 8, + moveSpeed: 7.5, + swordDamage: 50, + swordRange: 2.2, + thornDamage: 0, + }; mixer!: THREE.AnimationMixer; private clips: Map = new Map(); private currentAnim = ''; @@ -64,6 +91,8 @@ export class Player { // Weapons this.leftHandWeapon = new Staff(); this.rightHandWeapon = new Sword(); + this.skillLightning = new ChainLightning(); + this.leftHandWeapon.mesh.visible = false; this.body.add(this.leftHandWeapon.mesh); this.body.add(this.rightHandWeapon.mesh); } @@ -149,24 +178,60 @@ export class Player { } } - switchHands() { - const temp = this.leftHandWeapon; - this.leftHandWeapon = this.rightHandWeapon as unknown as Staff; - this.rightHandWeapon = temp as unknown as Sword; - - const leftPos = this.leftHandWeapon.mesh.position.clone(); - const rightPos = this.rightHandWeapon.mesh.position.clone(); - this.leftHandWeapon.mesh.position.copy(rightPos); - this.rightHandWeapon.mesh.position.copy(leftPos); - - const leftRot = this.leftHandWeapon.mesh.rotation.clone(); - const rightRot = this.rightHandWeapon.mesh.rotation.clone(); - this.leftHandWeapon.mesh.rotation.copy(rightRot); - this.rightHandWeapon.mesh.rotation.copy(leftRot); + heal(amount: number) { + this.health = Math.min(this.health + amount, this.stats.maxHealth); } - heal(amount: number) { - this.health = Math.min(this.health + amount, this.MAX_HEALTH); + damage(amount: number) { + if (this.shieldHp > 0) { + const absorbed = Math.min(this.shieldHp, amount); + this.shieldHp -= absorbed; + amount -= absorbed; + this.shieldTimer = this.stats.shieldRechargeDelay; + } + this.health -= amount; + } + + updatePassives(dt: number) { + if (this.stats.regen > 0) { + this.heal(this.stats.regen * dt); + } + if (this.stats.shield > 0) { + if (this.shieldHp < this.stats.shield) { + this.shieldTimer -= dt; + if (this.shieldTimer <= 0) { + this.shieldHp = this.stats.shield; + } + } else { + this.shieldTimer = 0; + } + } + } + + applySkillSystem(skills: SkillSystem) { + const oldMax = this.stats.maxHealth; + const l = (id: string) => skills.getLevel(id); + + this.stats = { + maxHealth: 100 + 25 * l('maxHp'), + regen: 1.5 * l('regen'), + lifesteal: 0.05 * l('lifesteal'), + shield: 25 * l('shield'), + shieldRechargeDelay: 8, + moveSpeed: 7.5 + l('speed'), + swordDamage: 50 + 25 * l('swordDamage'), + swordRange: 2.2 + 0.6 * l('swordRange'), + thornDamage: 15 * l('thorns'), + }; + + if (this.stats.maxHealth > oldMax) { + this.health = this.stats.maxHealth; + } else { + this.health = Math.min(this.health, this.stats.maxHealth); + } + if (this.stats.shield > 0 && this.shieldHp <= 0) { + this.shieldHp = this.stats.shield; + } } die() { @@ -174,7 +239,9 @@ export class Player { } reset() { - this.health = this.MAX_HEALTH; + this.health = 100; + this.shieldHp = 0; + this.shieldTimer = 0; this.body.position.set(0, 0.5, 0); this.velocity.set(0, 0, 0); this.jumpVelocity = 0; diff --git a/web/src/Skills.ts b/web/src/Skills.ts new file mode 100644 index 0000000..2bbe645 --- /dev/null +++ b/web/src/Skills.ts @@ -0,0 +1,212 @@ +export type SkillType = 'active' | 'passive'; + +export interface SkillDef { + id: string; + name: string; + type: SkillType; + maxLevel: number; + describe(level: number): string; +} + +export interface SkillOffer { + id: string; + nextLevel: number; +} + +export const SKILLS: SkillDef[] = [ + { + id: 'fireball', + name: 'Feuerball', + type: 'active', + maxLevel: 5, + describe: (l) => l === 1 + ? 'Schießt einen Feuerball (100 DMG, 5s CD)' + : `${fireballDamage(l)} DMG · ${fireballCooldown(l)}s CD${fireballRadius(l) > 8 ? ' · größere Explosion' : ''}`, + }, + { + id: 'teleport', + name: 'Teleport', + type: 'active', + maxLevel: 3, + describe: (l) => `Teleport zum Zielpunkt (${teleportCooldown(l)}s CD)`, + }, + { + id: 'chainlightning', + name: 'Kettenblitz', + type: 'active', + maxLevel: 5, + describe: (l) => `Blitz springt auf ${lightningJumps(l)} Gegner (${lightningDamage(l)} DMG, ${lightningCooldown(l)}s CD)`, + }, + { + id: 'swordRange', + name: 'Reichweite', + type: 'passive', + maxLevel: 5, + describe: (l) => `Schwert-Reichweite auf ${swordRange(l).toFixed(1)}`, + }, + { + id: 'swordDamage', + name: 'Schärfe', + type: 'passive', + maxLevel: 5, + describe: (l) => `Schwert-Schaden ${swordDamage(l)}`, + }, + { + id: 'regen', + name: 'Regeneration', + type: 'passive', + maxLevel: 5, + describe: (l) => `Heilt ${(1.5 * l).toFixed(1)} HP/s`, + }, + { + id: 'lifesteal', + name: 'HP-Absorption', + type: 'passive', + maxLevel: 5, + describe: (l) => `${5 * l}% des Schadens heilt dich`, + }, + { + id: 'shield', + name: 'Schild', + type: 'passive', + maxLevel: 5, + describe: (l) => `Absorbiert ${25 * l} Schaden, lädt nach 8s neu auf`, + }, + { + id: 'maxHp', + name: 'Vitalität', + type: 'passive', + maxLevel: 5, + describe: (l) => `+25 max. HP (${100 + 25 * l}), heilt voll`, + }, + { + id: 'thorns', + name: 'Dornen', + type: 'passive', + maxLevel: 3, + describe: (l) => `Gegner erleiden ${15 * l} DPS bei Kontakt`, + }, + { + id: 'speed', + name: 'Flinke Füße', + type: 'passive', + maxLevel: 3, + describe: (l) => `Bewegungsgeschwindigkeit ${(7.5 + l).toFixed(1)}`, + }, +]; + +export function getSkill(id: string): SkillDef { + return SKILLS.find((s) => s.id === id)!; +} + +export function fireballDamage(level: number): number { + return [100, 150, 200, 250, 300][Math.min(level, 5) - 1] ?? 100; +} +export function fireballCooldown(level: number): number { + return [5, 4.5, 4, 3.5, 3][Math.min(level, 5) - 1] ?? 5; +} +export function fireballRadius(level: number): number { + return [8, 8, 10, 10, 12][Math.min(level, 5) - 1] ?? 8; +} +export function teleportCooldown(level: number): number { + return [50, 35, 20][Math.min(level, 3) - 1] ?? 50; +} +export function lightningDamage(level: number): number { + return [40, 60, 80, 100, 120][Math.min(level, 5) - 1] ?? 40; +} +export function lightningJumps(level: number): number { + return [2, 3, 4, 5, 6][Math.min(level, 5) - 1] ?? 2; +} +export function lightningCooldown(level: number): number { + return [8, 7, 6, 5, 4][Math.min(level, 5) - 1] ?? 8; +} +export function swordRange(level: number): number { + return 2.2 + 0.6 * level; +} +export function swordDamage(level: number): number { + return 50 + 25 * level; +} + +function mulberry32(seed: number): () => number { + let a = seed >>> 0; + return function () { + a |= 0; + a = (a + 0x6D2B79F5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +export class SkillSystem { + seed: number; + level = 0; + xp = 0; + private rng: () => number; + levels: Record = {}; + + constructor(seed: number) { + this.seed = seed >>> 0; + this.rng = mulberry32(this.seed); + } + + getLevel(id: string): number { + return this.levels[id] ?? 0; + } + + xpNeeded(): number { + return 20 + this.level * 10; + } + + xpProgress(): number { + return this.xp / this.xpNeeded(); + } + + addXp(amount: number): boolean { + this.xp += amount; + if (!this.hasAnyUpgradeLeft()) return false; + if (this.xp >= this.xpNeeded()) { + this.xp -= this.xpNeeded(); + this.level++; + return true; + } + return false; + } + + hasPendingLevelUp(): boolean { + return this.hasAnyUpgradeLeft() && this.xp >= this.xpNeeded(); + } + + hasAnyUpgradeLeft(): boolean { + return SKILLS.some((s) => (this.levels[s.id] ?? 0) < s.maxLevel); + } + + apply(skillId: string) { + this.levels[skillId] = (this.levels[skillId] ?? 0) + 1; + } + + rollOffers(): SkillOffer[] { + const newPool = SKILLS.filter((s) => !this.levels[s.id]); + const upgradePool = SKILLS.filter((s) => { + const l = this.levels[s.id] ?? 0; + return l > 0 && l < s.maxLevel; + }); + + const ownedCount = Object.keys(this.levels).length; + const offers: SkillOffer[] = []; + while (offers.length < 3 && newPool.length + upgradePool.length > 0) { + let pool: SkillDef[]; + if (newPool.length && upgradePool.length) { + const newBias = ownedCount < 4 ? 0.65 : 0.25; + pool = this.rng() < newBias ? newPool : upgradePool; + } else { + pool = newPool.length ? newPool : upgradePool; + } + const idx = Math.floor(this.rng() * pool.length); + const skill = pool[idx]; + pool.splice(idx, 1); + offers.push({ id: skill.id, nextLevel: (this.levels[skill.id] ?? 0) + 1 }); + } + return offers; + } +} diff --git a/web/src/Slime.ts b/web/src/Slime.ts new file mode 100644 index 0000000..330381e --- /dev/null +++ b/web/src/Slime.ts @@ -0,0 +1,110 @@ +import * as THREE from 'three'; +import type { Game } from './Game'; +import { Enemy } from './Enemy'; +import { playSound } from './SoundManager'; + +export class Slime extends Enemy { + private model: THREE.Group; + private bodyMesh: THREE.Mesh; + private time = Math.random() * Math.PI * 2; + private size: number; + + constructor(size = 0) { + super(); + this.size = size; + + this.model = new THREE.Group(); + this.fadeOutModel = this.model; + this.body.add(this.model); + + const color = size === 0 ? 0x33cc88 : 0x66eebb; + const mat = new THREE.MeshToonMaterial({ color }); + this.bodyMesh = new THREE.Mesh(new THREE.SphereGeometry(0.6, 20, 16), mat); + this.bodyMesh.position.y = 0.55; + this.bodyMesh.castShadow = true; + this.registerFadeMesh(this.bodyMesh); + this.model.add(this.bodyMesh); + + // Cute eyes (face toward the player: -Z) + const eyeMat = new THREE.MeshToonMaterial({ color: 0x111122 }); + for (const side of [-1, 1]) { + const eye = new THREE.Mesh(new THREE.SphereGeometry(0.09, 10, 8), eyeMat); + eye.position.set(side * 0.18, 0.72, -0.42); + this.registerFadeMesh(eye); + this.model.add(eye); + } + + if (size === 0) { + this.health = 40; + this.speed = 3.2; + this.xp = 12; + this.contactDps = 25; + } else { + this.health = 10; + this.speed = 4; + this.xp = 3; + this.contactDps = 12; + this.contactRadius = 1.5; + this.model.scale.setScalar(0.55); + this.grace = 0.3; + } + } + + playAnim() { + // Procedural visuals only + } + + updateAnimation(dt: number) { + if (this.grace > 0) this.grace -= dt; + this.updateFadeDeath(dt, { duration: 0.7, shrink: 0.9 }); + } + + update(dt: number, game: Game) { + this.time += dt; + + // Squishy pulse + const pulse = 1 + Math.sin(this.time * 5) * 0.08; + this.model.scale.y = (this.size === 0 ? 1 : 0.55) * pulse; + const squash = 1 + (1 - pulse) * 0.5; + this.model.scale.x = (this.size === 0 ? 1 : 0.55) * squash; + this.model.scale.z = this.model.scale.x; + + // Hop-wiggle toward the player + const toPlayer = new THREE.Vector3() + .subVectors(game.player.body.position, this.body.position); + toPlayer.y = 0; + const dist = toPlayer.length(); + if (dist > 0.1) { + const dir = toPlayer.normalize(); + this.body.lookAt( + this.body.position.x + dir.x, + this.body.position.y, + this.body.position.z + dir.z + ); + let moveDir = dir.multiplyScalar(this.speed); + if (this.knockback > 0) { + moveDir = dir.multiplyScalar(-this.speed); + this.knockback -= dt; + } + this.body.position.x += moveDir.x * dt; + this.body.position.z += moveDir.z * dt; + game.clampToArena(this.body.position); + } + } + + protected onDeath(game: Game) { + this.beginFadeDeath(); + if (this.size === 0) { + playSound('spawn', 0.5); + for (let i = 0; i < 2; i++) { + const child = new Slime(1); + game.spawnEnemyInstance( + child, + this.body.position.x + (Math.random() - 0.5) * 1.2, + this.body.position.z + (Math.random() - 0.5) * 1.2, + 0.1 + ); + } + } + } +} diff --git a/web/src/Spider.ts b/web/src/Spider.ts index 809e21b..bddfc2b 100644 --- a/web/src/Spider.ts +++ b/web/src/Spider.ts @@ -132,7 +132,7 @@ export class Spider extends Enemy { } } - protected onDeath() { + protected onDeath(_game: Game) { this.playAnim('die', false); } } diff --git a/web/src/Staff.ts b/web/src/Staff.ts index f624501..1c4da3b 100644 --- a/web/src/Staff.ts +++ b/web/src/Staff.ts @@ -4,6 +4,7 @@ import { Player } from './Player'; import { Fireball } from './Fireball'; import { playSound } from './SoundManager'; import { loadGLB } from './MeshLoader'; +import { fireballDamage, fireballCooldown, fireballRadius, teleportCooldown } from './Skills'; export class Staff extends Weapon { constructor() { @@ -52,7 +53,11 @@ export class Staff extends Weapon { } skill1(groundPoint: THREE.Vector3, player: Player): void { + const level = player.game.skillSystem.getLevel('fireball'); + if (level <= 0) return; if (this.cooldown1 > 0) return; + + this.COOLDOWN1_TIME = fireballCooldown(level); this.cooldown1 = this.COOLDOWN1_TIME; // Spawn at staff height, fly toward the cursor point @@ -66,13 +71,19 @@ export class Staff extends Weapon { dir.setY(0); const fb = new Fireball(spawnPos, dir); + fb.damage = fireballDamage(level); + fb.explosionRadius = fireballRadius(level); player.game.scene.add(fb.body); player.game.fireballs.push(fb); playSound('fireball', 0.5); } skill2(groundPoint: THREE.Vector3, player: Player): void { + const level = player.game.skillSystem.getLevel('teleport'); + if (level <= 0) return; if (this.cooldown2 > 0) return; + + this.COOLDOWN2_TIME = teleportCooldown(level); this.cooldown2 = this.COOLDOWN2_TIME; const newPos = groundPoint.clone(); diff --git a/web/src/Sword.ts b/web/src/Sword.ts index c25a068..b779036 100644 --- a/web/src/Sword.ts +++ b/web/src/Sword.ts @@ -98,6 +98,9 @@ export class Sword extends Weapon { const { game } = player; + const range = player.stats.swordRange; + const damage = player.stats.swordDamage; + // Aim direction: where the blob actually faces (toward the mouse point) const aim = new THREE.Vector3() .subVectors(game.groundPoint, player.body.position); @@ -109,15 +112,19 @@ export class Sword extends Weapon { aim.normalize(); // Slash trail feedback - const slash = new SlashEffect(player.body.position, aim, game.scene); + const slash = new SlashEffect(player.body.position, aim, game.scene, range); game.slashEffects.push(slash); for (const enemy of game.enemies) { if (enemy.dead) continue; - if (isInSlashArc(player.body.position, aim, enemy.body.position)) { - enemy.takeDamage(50, game); + if (!enemy.canBeSlashHit()) continue; + if (isInSlashArc(player.body.position, aim, enemy.body.position, range)) { + enemy.takeDamage(damage, game, true, player.body.position); } } + + // Destroy enemy projectiles caught in the slash + game.destroyProjectilesInArc(player.body.position, aim, range); } skill2(): void { diff --git a/web/src/SwordTrail.ts b/web/src/SwordTrail.ts index 64cb225..1dc242e 100644 --- a/web/src/SwordTrail.ts +++ b/web/src/SwordTrail.ts @@ -2,11 +2,8 @@ import * as THREE from 'three'; const DURATION = 0.3; const SPARK_COUNT = 20; +const SLASH_HALF_ANGLE = 1.22; -// Hit zone of the sword slash. The trail visual is generated from these -// exact values so what the player sees is what can actually be hit. -export const SLASH_RADIUS = 2.2; -export const SLASH_HALF_ANGLE = 1.22; // Enemies are ~0.5-0.7 wide; allow their body to overlap the arc edge. const HIT_MARGIN = 0.6; @@ -30,13 +27,14 @@ function arcGeometry(r0: number, r1: number): THREE.ShapeGeometry { export function isInSlashArc( playerPos: THREE.Vector3, forward: THREE.Vector3, - targetPos: THREE.Vector3 + targetPos: THREE.Vector3, + radius: number ): boolean { const fwd = new THREE.Vector3(forward.x, 0, forward.z).normalize(); const toTarget = new THREE.Vector3().subVectors(targetPos, playerPos); toTarget.y = 0; const dist = toTarget.length(); - if (dist > SLASH_RADIUS + HIT_MARGIN) return false; + if (dist > radius + HIT_MARGIN) return false; if (dist < 0.01) return true; return fwd.dot(toTarget.normalize()) > Math.cos(SLASH_HALF_ANGLE); } @@ -54,7 +52,12 @@ export class SlashEffect { private t = 0; private done = false; - constructor(position: THREE.Vector3, forward: THREE.Vector3, scene: THREE.Scene) { + constructor( + position: THREE.Vector3, + forward: THREE.Vector3, + scene: THREE.Scene, + radius: number + ) { const yaw = Math.atan2(forward.x, forward.z); this.group = new THREE.Group(); this.group.position.copy(position); @@ -72,7 +75,7 @@ export class SlashEffect { side: THREE.DoubleSide, }); const outer = new THREE.Mesh( - arcGeometry(0.5, SLASH_RADIUS), + arcGeometry(0.5, radius), this.outerMat ); this.group.add(outer); @@ -87,7 +90,7 @@ export class SlashEffect { side: THREE.DoubleSide, }); const inner = new THREE.Mesh( - arcGeometry(0.7, SLASH_RADIUS * 0.8), + arcGeometry(0.7, radius * 0.8), this.innerMat ); this.group.add(inner); @@ -99,10 +102,10 @@ export class SlashEffect { for (let i = 0; i < SPARK_COUNT; i++) { const angle = -SLASH_HALF_ANGLE + (i / (SPARK_COUNT - 1)) * SLASH_HALF_ANGLE * 2; - const radius = 1.2 + Math.random() * (SLASH_RADIUS - 1.2); - this.sparkPositions[i * 3] = Math.sin(angle) * radius; + const sparkRadius = radius * 0.55 + Math.random() * radius * 0.45; + this.sparkPositions[i * 3] = Math.sin(angle) * sparkRadius; this.sparkPositions[i * 3 + 1] = 0.2 + Math.random() * 0.3; - this.sparkPositions[i * 3 + 2] = Math.cos(angle) * radius; + this.sparkPositions[i * 3 + 2] = Math.cos(angle) * sparkRadius; this.sparkVelocities[i * 3] = Math.sin(angle) * 2.5; this.sparkVelocities[i * 3 + 1] = 2 + Math.random() * 3; this.sparkVelocities[i * 3 + 2] = Math.cos(angle) * 2.5; diff --git a/web/src/Turret.ts b/web/src/Turret.ts new file mode 100644 index 0000000..445e64a --- /dev/null +++ b/web/src/Turret.ts @@ -0,0 +1,96 @@ +import * as THREE from 'three'; +import type { Game } from './Game'; +import { Enemy } from './Enemy'; + +const SHOOT_INTERVAL = 3; +const SHOOT_RANGE = 20; + +export class Turret extends Enemy { + private model: THREE.Group; + private cap: THREE.Mesh; + private shootTimer = 1 + Math.random(); + private swayTime = Math.random() * Math.PI * 2; + + constructor() { + super(); + this.health = 80; + this.speed = 0; + this.xp = 25; + this.contactDps = 0; + + this.model = new THREE.Group(); + this.fadeOutModel = this.model; + this.body.add(this.model); + + const stalkMat = new THREE.MeshToonMaterial({ color: 0xe8dcc0 }); + const capMat = new THREE.MeshToonMaterial({ color: 0xcc3344 }); + const spotMat = new THREE.MeshToonMaterial({ color: 0xfff0e0 }); + + // Stalk + const stalk = new THREE.Mesh(new THREE.CylinderGeometry(0.16, 0.28, 0.8, 10), stalkMat); + stalk.position.y = 0.4; + stalk.castShadow = true; + this.registerFadeMesh(stalk); + this.model.add(stalk); + + // Cap + this.cap = new THREE.Mesh(new THREE.SphereGeometry(0.5, 18, 14), capMat); + this.cap.scale.set(1, 0.65, 1); + this.cap.position.y = 0.9; + this.cap.castShadow = true; + this.registerFadeMesh(this.cap); + this.model.add(this.cap); + + // Spots + for (const [dx, dz] of [[0, 0], [0.28, 0.12], [-0.26, 0.18], [0.05, -0.3]] as const) { + const spot = new THREE.Mesh(new THREE.SphereGeometry(0.09, 10, 8), spotMat); + spot.scale.set(1, 0.45, 1); + spot.position.set(dx, 1.16, dz); + this.registerFadeMesh(spot); + this.model.add(spot); + } + } + + playAnim() { + // Procedural visuals only + } + + updateAnimation(dt: number) { + this.updateFadeDeath(dt, { duration: 0.9, sink: 0.3 }); + } + + update(dt: number, game: Game) { + this.swayTime += dt; + + // Slight sway + this.model.rotation.z = Math.sin(this.swayTime * 1.5) * 0.06; + this.cap.rotation.y = this.swayTime * 0.4; + + const playerPos = game.player.body.position; + const toPlayer = new THREE.Vector3().subVectors(playerPos, this.body.position); + toPlayer.y = 0; + const dist = toPlayer.length(); + + if (dist > 0.1) { + this.body.lookAt( + this.body.position.x + toPlayer.x, + this.body.position.y, + this.body.position.z + toPlayer.z + ); + } + + this.shootTimer -= dt; + if (dist < SHOOT_RANGE && this.shootTimer <= 0 && game.player.health > 0) { + this.shootTimer = SHOOT_INTERVAL + Math.random() * 0.5; + const from = this.body.position.clone(); + from.y += 1.0; + const target = playerPos.clone(); + target.y += 0.6; + game.spawnEnemyProjectile(from, target, 15); + } + } + + protected onDeath(_game: Game) { + this.beginFadeDeath(); + } +} diff --git a/web/vite.config.ts b/web/vite.config.ts index cd2bbcf..636cc5d 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -9,5 +9,8 @@ export default defineConfig({ server: { host: true, port: 3000, + watch: { + ignored: ['**/dist/**'], + }, }, });