Add main menu, ghost enemy, and sword slash trail

This commit is contained in:
2026-08-17 13:03:19 +02:00
parent 0b19c3d26b
commit 4751d428a7
8 changed files with 796 additions and 164 deletions
+150 -55
View File
@@ -1,15 +1,20 @@
import * as THREE from 'three';
import { Player } from './Player';
import { Enemy, initSpiderModel } from './Enemy';
import { Enemy } from './Enemy';
import { Spider, initSpiderModel } from './Spider';
import { Ghost } from './Ghost';
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 { initSounds, playSound, resumeContext } from './SoundManager';
const SPAWN_TIME = 10;
type GameState = 'menu' | 'playing' | 'gameover';
export class Game {
scene: THREE.Scene;
camera: THREE.PerspectiveCamera;
@@ -20,6 +25,9 @@ export class Game {
enemies: Enemy[] = [];
fireballs: Fireball[] = [];
crosses: Cross[] = [];
slashEffects: SlashEffect[] = [];
state: GameState = 'menu';
keys: Set<string> = new Set();
mouseButtons: Set<number> = new Set();
@@ -31,7 +39,6 @@ export class Game {
spawnTimer = 0;
spawns = 0;
killed = 0;
gameOver = false;
// Debug hitzone visualization
private showHitzones = false;
@@ -51,6 +58,9 @@ export class Game {
private uiWaveBarFill!: HTMLElement;
private uiWaveBarLabel!: HTMLElement;
private uiGameOver!: HTMLElement;
private uiGameOverPanel!: HTMLElement;
private uiGameOverStats!: HTMLElement;
private uiMainMenu!: HTMLElement;
private mouseOnCanvas = false;
constructor() {
@@ -115,6 +125,13 @@ export class Game {
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')!;
document.getElementById('btn-start')!.addEventListener('click', () => this.startGame());
document.getElementById('btn-restart')!.addEventListener('click', () => this.restart());
document.getElementById('btn-menu')!.addEventListener('click', () => this.showMenu());
const canvas = this.renderer.domElement;
canvas.addEventListener('mouseenter', () => this.mouseOnCanvas = true);
@@ -125,17 +142,21 @@ export class Game {
private setupInput() {
window.addEventListener('keydown', (e) => {
this.keys.add(e.code);
if (e.code === 'Space') {
if (e.code === 'Space' && this.state === 'playing') {
this.player.jump();
e.preventDefault();
}
if (e.code === 'KeyH') {
this.showHitzones = !this.showHitzones;
}
if (e.code === 'Enter' || e.code === 'Space') {
if (this.state === 'menu') this.startGame();
else if (this.state === 'gameover') this.restart();
}
});
window.addEventListener('keyup', (e) => {
this.keys.delete(e.code);
if (e.code === 'KeyE' && !this.gameOver) {
if (e.code === 'KeyE' && this.state === 'playing') {
this.player.leftHandWeapon.skill2(this.groundPoint, this.player);
}
});
@@ -157,7 +178,7 @@ export class Game {
}
private handleMouseRelease(button: number) {
if (this.gameOver) return;
if (this.state !== 'playing') return;
if (button === 0) {
this.player.leftHandWeapon.skill1(this.groundPoint, this.player);
} else if (button === 1) {
@@ -286,11 +307,104 @@ export class Game {
}
}
startGame() {
if (this.state === 'playing') return;
this.resetWorld();
this.state = 'playing';
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';
}
restart() {
this.startGame();
}
showMenu() {
this.resetWorld();
this.state = 'menu';
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';
}
private gameOver() {
this.state = 'gameover';
this.player.die();
this.uiGameOver.style.opacity = '1';
this.uiGameOverStats.textContent = `Wave ${this.spawns} · Kills: ${this.killed}`;
this.uiGameOverPanel.style.transition = 'opacity 0.8s ease-in 1.5s';
this.uiGameOverPanel.style.opacity = '1';
this.uiGameOverPanel.style.pointerEvents = 'auto';
playSound('gameover', 0.8);
}
private resetWorld() {
for (const enemy of this.enemies) {
enemy.dispose();
this.scene.remove(enemy.body);
}
this.enemies = [];
for (const fb of this.fireballs) this.scene.remove(fb.body);
this.fireballs = [];
for (const cross of this.crosses) this.scene.remove(cross.body);
this.crosses = [];
for (const slash of this.slashEffects) slash.dispose();
this.slashEffects = [];
for (const [enemy, ring] of this.enemyHitRings) {
this.scene.remove(ring);
ring.geometry.dispose();
(ring.material as THREE.Material).dispose();
this.enemyHitRings.delete(enemy);
}
for (const [fb, ring] of this.fireballHitRings) {
this.scene.remove(ring);
ring.geometry.dispose();
(ring.material as THREE.Material).dispose();
this.fireballHitRings.delete(fb);
}
for (const [cross, ring] of this.crossHitRings) {
this.scene.remove(ring);
ring.geometry.dispose();
(ring.material as THREE.Material).dispose();
this.crossHitRings.delete(cross);
}
this.spawnTimer = 0;
this.spawns = 0;
this.killed = 0;
this.player.reset();
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.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();
const x = (Math.random() - 0.5) * 80;
const z = (Math.random() - 0.5) * 80;
const enemy = new Enemy(this.player.body.position);
enemy.body.position.set(x, 0.1, z);
enemy.body.position.set(x, isGhost ? 1.0 : 0.1, z);
this.scene.add(enemy.body);
this.enemies.push(enemy);
}
@@ -319,7 +433,7 @@ export class Game {
}
private updatePlayerMovement(dt: number) {
if (this.gameOver) {
if (this.state !== 'playing') {
this.player.body.position.y = 0.5;
return;
}
@@ -379,45 +493,18 @@ export class Game {
);
}
private clampToArena(pos: THREE.Vector3) {
clampToArena(pos: THREE.Vector3) {
pos.x = Math.max(-47, Math.min(47, pos.x));
pos.z = Math.max(-47, Math.min(47, pos.z));
}
private updateEnemies(dt: number) {
for (const enemy of this.enemies) {
if (enemy.dead) {
if (enemy.dead || this.state !== 'playing') {
enemy.updateAnimation(dt);
continue;
}
const toPlayer = new THREE.Vector3()
.subVectors(this.player.body.position, enemy.body.position);
toPlayer.y = 0;
const dist = toPlayer.length();
if (dist > 0.1) {
const dir = toPlayer.normalize();
enemy.body.lookAt(
enemy.body.position.x + dir.x,
enemy.body.position.y,
enemy.body.position.z + dir.z
);
enemy.playAnim('walk');
let moveDir = dir.multiplyScalar(6);
if (enemy.knockback > 0) {
moveDir = dir.multiplyScalar(-6);
enemy.knockback -= dt;
}
enemy.body.position.x += moveDir.x * dt;
enemy.body.position.z += moveDir.z * dt;
this.clampToArena(enemy.body.position);
} else {
enemy.playAnim('stand');
}
enemy.update(dt, this);
enemy.updateAnimation(dt);
}
}
@@ -489,11 +576,8 @@ export class Game {
this.uiWaveBarFill.style.width = `${wavePct * 100}%`;
this.uiWaveBarLabel.textContent = `Next Wave: ${Math.ceil(SPAWN_TIME - this.spawnTimer)}s`;
if (this.player.health <= 0 && !this.gameOver) {
this.gameOver = true;
this.player.die();
this.uiGameOver.style.opacity = '1';
playSound('gameover', 0.8);
if (this.player.health <= 0 && this.state === 'playing') {
this.gameOver();
}
}
@@ -508,12 +592,19 @@ export class Game {
this.updateFireballs(dt);
this.updateHitzones();
// Update slash effects
for (let i = this.slashEffects.length - 1; i >= 0; i--) {
if (!this.slashEffects[i].update(dt)) {
this.slashEffects.splice(i, 1);
}
}
// Update animations
this.player.updateAnimations(dt);
this.player.rightHandWeapon.updateAnimation(dt);
// Spawn enemies in waves
if (!this.gameOver) {
if (this.state === 'playing') {
this.spawnTimer += dt;
if (this.spawnTimer >= SPAWN_TIME) {
this.spawns++;
@@ -524,12 +615,14 @@ export class Game {
}
}
// Check close encounters (spider touching player)
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;
// Check close encounters (enemy touching player)
if (this.state === 'playing') {
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;
}
}
}
@@ -537,11 +630,13 @@ export class Game {
for (let i = this.crosses.length - 1; i >= 0; i--) {
const cross = this.crosses[i];
cross.update(dt);
const dist = cross.body.position.distanceTo(this.player.body.position);
if (dist < 2) {
this.player.heal(25);
this.scene.remove(cross.body);
this.crosses.splice(i, 1);
if (this.state === 'playing') {
const dist = cross.body.position.distanceTo(this.player.body.position);
if (dist < 2) {
this.player.heal(25);
this.scene.remove(cross.body);
this.crosses.splice(i, 1);
}
}
}