+
Fireball
@@ -127,11 +216,16 @@
Sword
-
+
+
+
+
- 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/**'],
+ },
},
});