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
+48
View File
@@ -70,6 +70,38 @@
text-shadow: 4px 4px 8px #000; text-shadow: 4px 4px 8px #000;
transition: opacity 2s ease-in; transition: opacity 2s ease-in;
} }
#game-over-panel {
position: absolute; top: 62%; left: 50%; transform: translate(-50%, -50%);
display: flex; flex-direction: column; align-items: center; gap: 12px;
pointer-events: none; opacity: 0; transition: opacity 0.8s ease-in 1.5s;
font-family: monospace;
}
#game-over-stats {
color: #ffcc00; font-size: 20px; text-shadow: 2px 2px 4px #000;
}
.menu-button {
pointer-events: auto; cursor: pointer;
background: rgba(0,0,0,0.7); color: #fff; font-family: monospace;
font-size: 18px; padding: 10px 28px; border: 2px solid #888;
}
.menu-button:hover { background: #333; border-color: #ffcc00; color: #ffcc00; }
#main-menu {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 18px; pointer-events: auto; background: rgba(0,0,0,0.55);
transition: opacity 0.6s ease; font-family: monospace;
}
#main-menu h1 {
color: #fff; font-size: 72px; text-shadow: 4px 4px 8px #000;
}
#main-menu .subtitle {
color: #ffcc00; font-size: 20px; text-shadow: 2px 2px 4px #000;
}
#main-menu .menu-controls {
color: rgba(255,255,255,0.8); font-size: 13px; text-align: center;
line-height: 1.7;
}
#btn-start { font-size: 24px; padding: 14px 48px; }
#instructions { #instructions {
position: absolute; bottom: 16px; left: 50%; transform: translateX(-50%); position: absolute; bottom: 16px; left: 50%; transform: translateX(-50%);
color: rgba(255,255,255,0.5); font-size: 11px; font-family: monospace; color: rgba(255,255,255,0.5); font-size: 11px; font-family: monospace;
@@ -108,6 +140,22 @@
</div> </div>
<div id="game-over">Verloren!</div> <div id="game-over">Verloren!</div>
<div id="game-over-panel">
<div id="game-over-stats"></div>
<button id="btn-restart" class="menu-button">Nochmal spielen</button>
<button id="btn-menu" class="menu-button">Hauptmenü</button>
</div>
<div id="main-menu">
<h1>WACKELPETER</h1>
<div class="subtitle">Ein Blob kämpft ums Überleben</div>
<div class="menu-controls">
WASD: Bewegen · Space: Springen · Maus: Zielen<br>
Linksklick: Feuerball · Rechtsklick: Schwert · M3: Hände tauschen · E: Teleport
</div>
<button id="btn-start" class="menu-button">Start</button>
</div>
<div id="instructions"> <div id="instructions">
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: Fireball · Right Click: Sword · M3: Swap hands · E: Teleport
</div> </div>
+20 -99
View File
@@ -1,115 +1,29 @@
import * as THREE from 'three'; import * as THREE from 'three';
import type { Game } from './Game'; import type { Game } from './Game';
import { playSound, playLoopingSound } from './SoundManager'; import { playSound } from './SoundManager';
import { loadGLB } from './MeshLoader';
import type { GLTF } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { clone as cloneSkeleton } from 'three/examples/jsm/utils/SkeletonUtils.js';
let spiderGLTF: GLTF | null = null; export abstract class Enemy {
const ANIMATIONS_ENABLED = true;
export async function initSpiderModel() {
try {
spiderGLTF = await loadGLB('spider');
} catch {
console.warn('Failed to load spider model');
}
}
export class Enemy {
body: THREE.Group; body: THREE.Group;
dead = false; dead = false;
deathTimer = 0; deathTimer = 0;
knockback = 0; knockback = 0;
health = 100; health = 100;
speed = 6;
mixer: THREE.AnimationMixer | null = null; mixer: THREE.AnimationMixer | null = null;
private currentAnim = ''; protected clips: Map<string, THREE.AnimationAction> = new Map();
private clips: Map<string, THREE.AnimationAction> = new Map(); protected currentAnim = '';
private stopWalkSound: (() => void) | null = null; protected stopWalkSound: (() => void) | null = null;
constructor(_playerPos: THREE.Vector3) { constructor() {
this.body = new THREE.Group(); this.body = new THREE.Group();
this.stopWalkSound = playLoopingSound('spiderwalking', 0.3);
if (spiderGLTF) {
const model = cloneSkeleton(spiderGLTF.scene);
// Original game scaled the spider model by 0.5 (WalkingEnemy.setupModel)
model.scale.set(0.5, 0.5, 0.5);
const spiderTexture = new THREE.TextureLoader().load('./textures/spider_animation2.png');
spiderTexture.flipY = false;
spiderTexture.colorSpace = THREE.SRGBColorSpace;
model.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.castShadow = true;
child.material = new THREE.MeshToonMaterial({ map: spiderTexture });
}
});
this.body.add(model);
if (spiderGLTF.animations.length > 0 && ANIMATIONS_ENABLED) {
this.mixer = new THREE.AnimationMixer(model);
for (const clip of spiderGLTF.animations) {
const action = this.mixer.clipAction(clip);
this.clips.set(clip.name, action);
}
this.playAnim('stand');
}
} else {
// Fallback
const abdomenGeom = new THREE.SphereGeometry(0.5, 8, 8);
const bodyMat = new THREE.MeshToonMaterial({ color: 0x333333 });
const abdomen = new THREE.Mesh(abdomenGeom, bodyMat);
abdomen.scale.set(1, 0.6, 1.3);
abdomen.position.y = 0.6;
abdomen.castShadow = true;
this.body.add(abdomen);
const cephalothoraxGeom = new THREE.SphereGeometry(0.3, 8, 8);
const cephalothorax = new THREE.Mesh(cephalothoraxGeom, bodyMat);
cephalothorax.position.y = 0.6;
cephalothorax.position.z = 0.5;
cephalothorax.castShadow = true;
this.body.add(cephalothorax);
const eyeGeom = new THREE.SphereGeometry(0.06, 6, 6);
const eyeMat = new THREE.MeshBasicMaterial({ color: 0xff0000 });
for (const sign of [-1, 1]) {
const eye = new THREE.Mesh(eyeGeom, eyeMat);
eye.position.set(sign * 0.12, 0.85, 0.7);
this.body.add(eye);
}
const legGeom = new THREE.CylinderGeometry(0.04, 0.04, 0.8, 4);
for (let i = 0; i < 8; i++) {
const leg = new THREE.Mesh(legGeom, new THREE.MeshToonMaterial({ color: 0x222222 }));
const angle = (i / 8) * Math.PI * 2;
const side = i < 4 ? 1 : -1;
leg.position.set(Math.cos(angle) * 0.35, 0.3, Math.sin(angle) * 0.35);
leg.rotation.z = side * 0.6;
leg.rotation.x = angle;
leg.castShadow = true;
this.body.add(leg);
}
}
} }
playAnim(name: string, loop = true, speed = 1) { abstract playAnim(name: string, loop?: boolean, speed?: number): void;
if (!this.clips.has(name) || this.currentAnim === name) return; abstract updateAnimation(dt: number): void;
// Stop the previous animation so it doesn't blend with the new one abstract update(dt: number, game: Game): void;
if (this.currentAnim) {
const prev = this.clips.get(this.currentAnim);
if (prev) prev.stop();
}
this.currentAnim = name;
const action = this.clips.get(name)!;
action.reset();
action.setLoop(loop ? THREE.LoopRepeat : THREE.LoopOnce, loop ? Infinity : 1);
action.clampWhenFinished = !loop;
action.setEffectiveTimeScale(speed);
action.play();
}
updateAnimation(dt: number) { protected onDeath() {
if (this.mixer) this.mixer.update(dt); // Subclasses override for model-specific death visuals
} }
takeDamage(damage: number, game: Game) { takeDamage(damage: number, game: Game) {
@@ -127,11 +41,18 @@ export class Enemy {
private die(game: Game) { private die(game: Game) {
if (this.dead) return; if (this.dead) return;
this.dead = true; this.dead = true;
this.playAnim('die', false); this.onDeath();
if (this.stopWalkSound) { if (this.stopWalkSound) {
this.stopWalkSound(); this.stopWalkSound();
this.stopWalkSound = null; this.stopWalkSound = null;
} }
game.removeEnemy(this); game.removeEnemy(this);
} }
dispose() {
if (this.stopWalkSound) {
this.stopWalkSound();
this.stopWalkSound = null;
}
}
} }
+150 -55
View File
@@ -1,15 +1,20 @@
import * as THREE from 'three'; import * as THREE from 'three';
import { Player } from './Player'; 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 { Sword } from './Sword';
import { Staff } from './Staff'; import { Staff } from './Staff';
import { Fireball } from './Fireball'; import { Fireball } from './Fireball';
import { Cross, initCrossModel } from './Cross'; import { Cross, initCrossModel } from './Cross';
import { Arena } from './Arena'; import { Arena } from './Arena';
import { SlashEffect } from './SwordTrail';
import { initSounds, playSound, resumeContext } from './SoundManager'; import { initSounds, playSound, resumeContext } from './SoundManager';
const SPAWN_TIME = 10; const SPAWN_TIME = 10;
type GameState = 'menu' | 'playing' | 'gameover';
export class Game { export class Game {
scene: THREE.Scene; scene: THREE.Scene;
camera: THREE.PerspectiveCamera; camera: THREE.PerspectiveCamera;
@@ -20,6 +25,9 @@ export class Game {
enemies: Enemy[] = []; enemies: Enemy[] = [];
fireballs: Fireball[] = []; fireballs: Fireball[] = [];
crosses: Cross[] = []; crosses: Cross[] = [];
slashEffects: SlashEffect[] = [];
state: GameState = 'menu';
keys: Set<string> = new Set(); keys: Set<string> = new Set();
mouseButtons: Set<number> = new Set(); mouseButtons: Set<number> = new Set();
@@ -31,7 +39,6 @@ export class Game {
spawnTimer = 0; spawnTimer = 0;
spawns = 0; spawns = 0;
killed = 0; killed = 0;
gameOver = false;
// Debug hitzone visualization // Debug hitzone visualization
private showHitzones = false; private showHitzones = false;
@@ -51,6 +58,9 @@ export class Game {
private uiWaveBarFill!: HTMLElement; private uiWaveBarFill!: HTMLElement;
private uiWaveBarLabel!: HTMLElement; private uiWaveBarLabel!: HTMLElement;
private uiGameOver!: HTMLElement; private uiGameOver!: HTMLElement;
private uiGameOverPanel!: HTMLElement;
private uiGameOverStats!: HTMLElement;
private uiMainMenu!: HTMLElement;
private mouseOnCanvas = false; private mouseOnCanvas = false;
constructor() { constructor() {
@@ -115,6 +125,13 @@ export class Game {
this.uiWaveBarFill = document.getElementById('wave-bar-fill')!; this.uiWaveBarFill = document.getElementById('wave-bar-fill')!;
this.uiWaveBarLabel = document.getElementById('wave-bar-label')!; this.uiWaveBarLabel = document.getElementById('wave-bar-label')!;
this.uiGameOver = document.getElementById('game-over')!; 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; const canvas = this.renderer.domElement;
canvas.addEventListener('mouseenter', () => this.mouseOnCanvas = true); canvas.addEventListener('mouseenter', () => this.mouseOnCanvas = true);
@@ -125,17 +142,21 @@ export class Game {
private setupInput() { private setupInput() {
window.addEventListener('keydown', (e) => { window.addEventListener('keydown', (e) => {
this.keys.add(e.code); this.keys.add(e.code);
if (e.code === 'Space') { if (e.code === 'Space' && this.state === 'playing') {
this.player.jump(); this.player.jump();
e.preventDefault(); e.preventDefault();
} }
if (e.code === 'KeyH') { if (e.code === 'KeyH') {
this.showHitzones = !this.showHitzones; 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) => { window.addEventListener('keyup', (e) => {
this.keys.delete(e.code); 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); this.player.leftHandWeapon.skill2(this.groundPoint, this.player);
} }
}); });
@@ -157,7 +178,7 @@ export class Game {
} }
private handleMouseRelease(button: number) { private handleMouseRelease(button: number) {
if (this.gameOver) return; if (this.state !== 'playing') return;
if (button === 0) { if (button === 0) {
this.player.leftHandWeapon.skill1(this.groundPoint, this.player); this.player.leftHandWeapon.skill1(this.groundPoint, this.player);
} else if (button === 1) { } 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() { 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 x = (Math.random() - 0.5) * 80;
const z = (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, isGhost ? 1.0 : 0.1, z);
enemy.body.position.set(x, 0.1, z);
this.scene.add(enemy.body); this.scene.add(enemy.body);
this.enemies.push(enemy); this.enemies.push(enemy);
} }
@@ -319,7 +433,7 @@ export class Game {
} }
private updatePlayerMovement(dt: number) { private updatePlayerMovement(dt: number) {
if (this.gameOver) { if (this.state !== 'playing') {
this.player.body.position.y = 0.5; this.player.body.position.y = 0.5;
return; 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.x = Math.max(-47, Math.min(47, pos.x));
pos.z = Math.max(-47, Math.min(47, pos.z)); pos.z = Math.max(-47, Math.min(47, pos.z));
} }
private updateEnemies(dt: number) { private updateEnemies(dt: number) {
for (const enemy of this.enemies) { for (const enemy of this.enemies) {
if (enemy.dead) { if (enemy.dead || this.state !== 'playing') {
enemy.updateAnimation(dt); enemy.updateAnimation(dt);
continue; continue;
} }
enemy.update(dt, this);
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.updateAnimation(dt); enemy.updateAnimation(dt);
} }
} }
@@ -489,11 +576,8 @@ export class Game {
this.uiWaveBarFill.style.width = `${wavePct * 100}%`; this.uiWaveBarFill.style.width = `${wavePct * 100}%`;
this.uiWaveBarLabel.textContent = `Next Wave: ${Math.ceil(SPAWN_TIME - this.spawnTimer)}s`; this.uiWaveBarLabel.textContent = `Next Wave: ${Math.ceil(SPAWN_TIME - this.spawnTimer)}s`;
if (this.player.health <= 0 && !this.gameOver) { if (this.player.health <= 0 && this.state === 'playing') {
this.gameOver = true; this.gameOver();
this.player.die();
this.uiGameOver.style.opacity = '1';
playSound('gameover', 0.8);
} }
} }
@@ -508,12 +592,19 @@ export class Game {
this.updateFireballs(dt); this.updateFireballs(dt);
this.updateHitzones(); 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 // Update animations
this.player.updateAnimations(dt); this.player.updateAnimations(dt);
this.player.rightHandWeapon.updateAnimation(dt); this.player.rightHandWeapon.updateAnimation(dt);
// Spawn enemies in waves // Spawn enemies in waves
if (!this.gameOver) { if (this.state === 'playing') {
this.spawnTimer += dt; this.spawnTimer += dt;
if (this.spawnTimer >= SPAWN_TIME) { if (this.spawnTimer >= SPAWN_TIME) {
this.spawns++; this.spawns++;
@@ -524,12 +615,14 @@ export class Game {
} }
} }
// Check close encounters (spider touching player) // Check close encounters (enemy touching player)
for (const enemy of this.enemies) { if (this.state === 'playing') {
if (enemy.dead) continue; for (const enemy of this.enemies) {
const dist = enemy.body.position.distanceTo(this.player.body.position); if (enemy.dead) continue;
if (dist < 2) { const dist = enemy.body.position.distanceTo(this.player.body.position);
this.player.health -= 25 * dt; 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--) { for (let i = this.crosses.length - 1; i >= 0; i--) {
const cross = this.crosses[i]; const cross = this.crosses[i];
cross.update(dt); cross.update(dt);
const dist = cross.body.position.distanceTo(this.player.body.position); if (this.state === 'playing') {
if (dist < 2) { const dist = cross.body.position.distanceTo(this.player.body.position);
this.player.heal(25); if (dist < 2) {
this.scene.remove(cross.body); this.player.heal(25);
this.crosses.splice(i, 1); this.scene.remove(cross.body);
this.crosses.splice(i, 1);
}
} }
} }
+245
View File
@@ -0,0 +1,245 @@
import * as THREE from 'three';
import type { Game } from './Game';
import { Enemy } from './Enemy';
const FLOAT_HEIGHT = 1.0;
const BOB_AMPLITUDE = 0.3;
const BOB_SPEED = 2.4;
const VISIBLE_TIME = 2.5;
const FADE_TIME = 0.35;
const HIDDEN_MIN = 0.6;
const HIDDEN_MAX = 1.5;
type VisState = 'visible' | 'fadingOut' | 'hidden' | 'fadingIn';
export class Ghost extends Enemy {
private model: THREE.Group;
private shadow: THREE.Mesh;
private shadowMat: THREE.MeshBasicMaterial;
private whiteMat: THREE.MeshToonMaterial;
private darkMat: THREE.MeshToonMaterial;
private blushMat: THREE.MeshToonMaterial;
private shadowCasters: THREE.Mesh[] = [];
private arms: THREE.Mesh[] = [];
private bobTime = Math.random() * Math.PI * 2;
private visState: VisState = 'visible';
private visTimer = VISIBLE_TIME + Math.random();
private dying = false;
private dieTime = 0;
constructor() {
super();
this.health = 40;
this.speed = 7.5;
this.model = new THREE.Group();
this.body.add(this.model);
this.whiteMat = new THREE.MeshToonMaterial({
color: 0xf8f8ff,
emissive: 0x2a2a3a,
transparent: true,
opacity: 0.96,
});
this.darkMat = new THREE.MeshToonMaterial({
color: 0x1c1c2e,
transparent: true,
opacity: 1,
});
this.blushMat = new THREE.MeshToonMaterial({
color: 0xffaacc,
transparent: true,
opacity: 0.85,
});
// Meringue body: stacked blobs tapering to a swirl peak
const base = new THREE.Mesh(new THREE.SphereGeometry(0.62, 24, 18), this.whiteMat);
base.scale.set(1.05, 0.8, 1.05);
base.position.y = 0.3;
this.addCaster(base);
const mid = new THREE.Mesh(new THREE.SphereGeometry(0.45, 20, 16), this.whiteMat);
mid.position.y = 0.78;
this.addCaster(mid);
const tip = new THREE.Mesh(new THREE.SphereGeometry(0.27, 16, 12), this.whiteMat);
tip.position.y = 1.18;
this.addCaster(tip);
// Swirl peak (little curled top like a meringue)
const swirl = new THREE.Mesh(new THREE.TorusGeometry(0.2, 0.075, 8, 20), this.whiteMat);
swirl.position.y = 1.3;
swirl.rotation.x = -0.6;
this.addCaster(swirl);
const curl = new THREE.Mesh(new THREE.SphereGeometry(0.1, 10, 8), this.whiteMat);
curl.position.set(0, 1.48, -0.02);
this.addCaster(curl);
// Stubby little arms
const armGeom = new THREE.CapsuleGeometry(0.09, 0.16, 4, 10);
for (const side of [-1, 1]) {
const arm = new THREE.Mesh(armGeom, this.whiteMat);
arm.position.set(side * 0.72, 0.72, 0.12);
arm.rotation.z = side * 0.55;
arm.rotation.x = -0.35;
this.addCaster(arm);
this.arms.push(arm);
}
// Face
const eyeGeom = new THREE.SphereGeometry(0.09, 12, 10);
for (const side of [-1, 1]) {
const eye = new THREE.Mesh(eyeGeom, this.darkMat);
eye.position.set(side * 0.2, 0.85, 0.44);
this.model.add(eye);
}
const mouth = new THREE.Mesh(
new THREE.TorusGeometry(0.09, 0.025, 6, 14, Math.PI),
this.darkMat
);
mouth.position.set(0, 0.66, 0.48);
mouth.rotation.z = Math.PI;
this.model.add(mouth);
for (const side of [-1, 1]) {
const blush = new THREE.Mesh(new THREE.SphereGeometry(0.075, 10, 8), this.blushMat);
blush.position.set(side * 0.34, 0.68, 0.38);
this.model.add(blush);
}
// Blob shadow on the ground (floating ghost, no real shadow casting)
this.shadowMat = new THREE.MeshBasicMaterial({
color: 0x000000,
transparent: true,
opacity: 0.25,
});
this.shadow = new THREE.Mesh(new THREE.CircleGeometry(0.55, 20), this.shadowMat);
this.shadow.rotation.x = -Math.PI / 2;
this.shadow.renderOrder = 998;
this.body.add(this.shadow);
}
private addCaster(mesh: THREE.Mesh) {
mesh.castShadow = true;
this.model.add(mesh);
this.shadowCasters.push(mesh);
}
playAnim() {
// No rigged animations, visuals are procedural
}
updateAnimation(dt: number) {
if (!this.dying) return;
this.dieTime += dt;
const t = Math.min(this.dieTime / 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);
this.setOpacity(1 - t);
}
update(dt: number, game: Game) {
this.bobTime += dt * BOB_SPEED;
this.body.position.y = FLOAT_HEIGHT + Math.sin(this.bobTime) * BOB_AMPLITUDE;
// Shadow stays on the ground
this.shadow.position.y = 0.06 - this.body.position.y;
// Gentle squash & stretch
const squash = 1 + Math.sin(this.bobTime * 2) * 0.05;
this.model.scale.y = squash;
this.model.scale.x = 1 + (1 - squash) * 0.5;
this.model.scale.z = this.model.scale.x;
// Arm wiggle
for (let i = 0; i < this.arms.length; i++) {
const side = i === 0 ? -1 : 1;
this.arms[i].rotation.z = side * 0.55 + Math.sin(this.bobTime + i * Math.PI) * 0.2;
}
// Invisibility cycle
this.visTimer -= dt;
switch (this.visState) {
case 'visible':
if (this.visTimer <= 0) {
this.visState = 'fadingOut';
this.visTimer = FADE_TIME;
}
break;
case 'fadingOut': {
const op = Math.max(0, this.visTimer / FADE_TIME);
this.setOpacity(op);
if (this.visTimer <= 0) {
this.visState = 'hidden';
this.visTimer = HIDDEN_MIN + Math.random() * (HIDDEN_MAX - HIDDEN_MIN);
}
break;
}
case 'hidden':
this.setOpacity(0);
if (this.visTimer <= 0) {
this.visState = 'fadingIn';
this.visTimer = FADE_TIME;
}
break;
case 'fadingIn': {
const op = 1 - Math.max(0, this.visTimer / FADE_TIME);
this.setOpacity(op);
if (this.visTimer <= 0) {
this.visState = 'visible';
this.visTimer = VISIBLE_TIME + Math.random() * 0.5;
}
break;
}
}
// Chase the player (stays hittable and dangerous even while invisible)
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);
}
}
private setOpacity(op: number) {
this.whiteMat.opacity = 0.96 * op;
this.darkMat.opacity = op;
this.blushMat.opacity = 0.85 * op;
this.shadowMat.opacity = 0.25 * op;
const casts = op > 0.4;
for (const mesh of this.shadowCasters) mesh.castShadow = casts;
}
protected onDeath() {
this.dying = true;
}
dispose() {
super.dispose();
this.whiteMat.dispose();
this.darkMat.dispose();
this.blushMat.dispose();
this.shadowMat.dispose();
this.shadow.geometry.dispose();
}
}
+9
View File
@@ -172,4 +172,13 @@ export class Player {
die() { die() {
this.playAnimation('die', false); this.playAnimation('die', false);
} }
reset() {
this.health = this.MAX_HEALTH;
this.body.position.set(0, 0.5, 0);
this.velocity.set(0, 0, 0);
this.jumpVelocity = 0;
this.onGround = true;
this.playAnimation('stand');
}
} }
+138
View File
@@ -0,0 +1,138 @@
import * as THREE from 'three';
import type { Game } from './Game';
import { Enemy } from './Enemy';
import { playLoopingSound } from './SoundManager';
import { loadGLB } from './MeshLoader';
import type { GLTF } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { clone as cloneSkeleton } from 'three/examples/jsm/utils/SkeletonUtils.js';
let spiderGLTF: GLTF | null = null;
const ANIMATIONS_ENABLED = true;
export async function initSpiderModel() {
try {
spiderGLTF = await loadGLB('spider');
} catch {
console.warn('Failed to load spider model');
}
}
export class Spider extends Enemy {
constructor() {
super();
this.stopWalkSound = playLoopingSound('spiderwalking', 0.3);
if (spiderGLTF) {
const model = cloneSkeleton(spiderGLTF.scene);
// Original game scaled the spider model by 0.5 (WalkingEnemy.setupModel)
model.scale.set(0.5, 0.5, 0.5);
const spiderTexture = new THREE.TextureLoader().load('./textures/spider_animation2.png');
spiderTexture.flipY = false;
spiderTexture.colorSpace = THREE.SRGBColorSpace;
model.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.castShadow = true;
child.material = new THREE.MeshToonMaterial({ map: spiderTexture });
}
});
this.body.add(model);
if (spiderGLTF.animations.length > 0 && ANIMATIONS_ENABLED) {
this.mixer = new THREE.AnimationMixer(model);
for (const clip of spiderGLTF.animations) {
const action = this.mixer.clipAction(clip);
this.clips.set(clip.name, action);
}
this.playAnim('stand');
}
} else {
// Fallback
const abdomenGeom = new THREE.SphereGeometry(0.5, 8, 8);
const bodyMat = new THREE.MeshToonMaterial({ color: 0x333333 });
const abdomen = new THREE.Mesh(abdomenGeom, bodyMat);
abdomen.scale.set(1, 0.6, 1.3);
abdomen.position.y = 0.6;
abdomen.castShadow = true;
this.body.add(abdomen);
const cephalothoraxGeom = new THREE.SphereGeometry(0.3, 8, 8);
const cephalothorax = new THREE.Mesh(cephalothoraxGeom, bodyMat);
cephalothorax.position.y = 0.6;
cephalothorax.position.z = 0.5;
cephalothorax.castShadow = true;
this.body.add(cephalothorax);
const eyeGeom = new THREE.SphereGeometry(0.06, 6, 6);
const eyeMat = new THREE.MeshBasicMaterial({ color: 0xff0000 });
for (const sign of [-1, 1]) {
const eye = new THREE.Mesh(eyeGeom, eyeMat);
eye.position.set(sign * 0.12, 0.85, 0.7);
this.body.add(eye);
}
const legGeom = new THREE.CylinderGeometry(0.04, 0.04, 0.8, 4);
for (let i = 0; i < 8; i++) {
const leg = new THREE.Mesh(legGeom, new THREE.MeshToonMaterial({ color: 0x222222 }));
const angle = (i / 8) * Math.PI * 2;
const side = i < 4 ? 1 : -1;
leg.position.set(Math.cos(angle) * 0.35, 0.3, Math.sin(angle) * 0.35);
leg.rotation.z = side * 0.6;
leg.rotation.x = angle;
leg.castShadow = true;
this.body.add(leg);
}
}
}
playAnim(name: string, loop = true, speed = 1) {
if (!this.clips.has(name) || this.currentAnim === name) return;
// Stop the previous animation so it doesn't blend with the new one
if (this.currentAnim) {
const prev = this.clips.get(this.currentAnim);
if (prev) prev.stop();
}
this.currentAnim = name;
const action = this.clips.get(name)!;
action.reset();
action.setLoop(loop ? THREE.LoopRepeat : THREE.LoopOnce, loop ? Infinity : 1);
action.clampWhenFinished = !loop;
action.setEffectiveTimeScale(speed);
action.play();
}
updateAnimation(dt: number) {
if (this.mixer) this.mixer.update(dt);
}
update(dt: number, game: Game) {
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
);
this.playAnim('walk');
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);
} else {
this.playAnim('stand');
}
}
protected onDeath() {
this.playAnim('die', false);
}
}
+17 -10
View File
@@ -3,6 +3,7 @@ import { Weapon } from './Weapon';
import { Player } from './Player'; import { Player } from './Player';
import { loadGLB } from './MeshLoader'; import { loadGLB } from './MeshLoader';
import { playSound } from './SoundManager'; import { playSound } from './SoundManager';
import { SlashEffect, isInSlashArc } from './SwordTrail';
export class Sword extends Weapon { export class Sword extends Weapon {
private mixer: THREE.AnimationMixer | null = null; private mixer: THREE.AnimationMixer | null = null;
@@ -97,18 +98,24 @@ export class Sword extends Weapon {
const { game } = player; const { game } = player;
// Aim direction: where the blob actually faces (toward the mouse point)
const aim = new THREE.Vector3()
.subVectors(game.groundPoint, player.body.position);
aim.y = 0;
if (aim.lengthSq() < 0.001) {
aim.set(0, 0, -1).applyQuaternion(player.body.quaternion);
aim.y = 0;
}
aim.normalize();
// Slash trail feedback
const slash = new SlashEffect(player.body.position, aim, game.scene);
game.slashEffects.push(slash);
for (const enemy of game.enemies) { for (const enemy of game.enemies) {
if (enemy.dead) continue; if (enemy.dead) continue;
const dist = enemy.body.position.distanceTo(player.body.position); if (isInSlashArc(player.body.position, aim, enemy.body.position)) {
if (dist < 3) { enemy.takeDamage(50, game);
const toEnemy = new THREE.Vector3()
.subVectors(enemy.body.position, player.body.position).normalize();
const playerFwd = new THREE.Vector3(0, 0, 1);
playerFwd.applyQuaternion(player.body.quaternion);
const dot = playerFwd.dot(toEnemy);
if (dot > 0.3) {
enemy.takeDamage(50, game);
}
} }
} }
} }
+169
View File
@@ -0,0 +1,169 @@
import * as THREE from 'three';
const DURATION = 0.3;
const SPARK_COUNT = 20;
// 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;
function arcShape(r0: number, r1: number): THREE.Shape {
const shape = new THREE.Shape();
shape.absarc(0, 0, r1, -SLASH_HALF_ANGLE, SLASH_HALF_ANGLE, false);
shape.absarc(0, 0, r0, SLASH_HALF_ANGLE, -SLASH_HALF_ANGLE, true);
shape.closePath();
return shape;
}
// ShapeGeometry builds the arc around +X in the XY plane; rotate it so it
// lies flat on the ground and is centered on +Z (the player's forward).
function arcGeometry(r0: number, r1: number): THREE.ShapeGeometry {
const geom = new THREE.ShapeGeometry(arcShape(r0, r1), 32);
geom.rotateX(-Math.PI / 2);
geom.rotateY(-Math.PI / 2);
return geom;
}
export function isInSlashArc(
playerPos: THREE.Vector3,
forward: THREE.Vector3,
targetPos: THREE.Vector3
): 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 < 0.01) return true;
return fwd.dot(toTarget.normalize()) > Math.cos(SLASH_HALF_ANGLE);
}
export class SlashEffect {
private group: THREE.Group;
private outerMat: THREE.MeshBasicMaterial;
private innerMat: THREE.MeshBasicMaterial;
private sparkMat: THREE.PointsMaterial;
private sparks: THREE.Points;
private sparkVelocities: Float32Array;
private sparkLives: Float32Array;
private sparkMaxLife = 0.25;
private sparkPositions: Float32Array;
private t = 0;
private done = false;
constructor(position: THREE.Vector3, forward: THREE.Vector3, scene: THREE.Scene) {
const yaw = Math.atan2(forward.x, forward.z);
this.group = new THREE.Group();
this.group.position.copy(position);
this.group.position.y = 0.12;
this.group.rotation.y = yaw;
scene.add(this.group);
// Outer soft arc (marks the full hit area)
this.outerMat = new THREE.MeshBasicMaterial({
color: 0xffffff,
transparent: true,
opacity: 0.5,
blending: THREE.AdditiveBlending,
depthWrite: false,
side: THREE.DoubleSide,
});
const outer = new THREE.Mesh(
arcGeometry(0.5, SLASH_RADIUS),
this.outerMat
);
this.group.add(outer);
// Inner hot arc
this.innerMat = new THREE.MeshBasicMaterial({
color: 0xffffcc,
transparent: true,
opacity: 0.85,
blending: THREE.AdditiveBlending,
depthWrite: false,
side: THREE.DoubleSide,
});
const inner = new THREE.Mesh(
arcGeometry(0.7, SLASH_RADIUS * 0.8),
this.innerMat
);
this.group.add(inner);
// Sparks along the arc (centered on +Z, the forward direction)
this.sparkPositions = new Float32Array(SPARK_COUNT * 3);
this.sparkVelocities = new Float32Array(SPARK_COUNT * 3);
this.sparkLives = new Float32Array(SPARK_COUNT);
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;
this.sparkPositions[i * 3 + 1] = 0.2 + Math.random() * 0.3;
this.sparkPositions[i * 3 + 2] = Math.cos(angle) * radius;
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;
this.sparkLives[i] = this.sparkMaxLife * (0.5 + Math.random());
}
const sparkGeom = new THREE.BufferGeometry();
sparkGeom.setAttribute(
'position',
new THREE.BufferAttribute(this.sparkPositions, 3)
);
this.sparkMat = new THREE.PointsMaterial({
color: 0xffffff,
size: 0.12,
blending: THREE.AdditiveBlending,
depthWrite: false,
transparent: true,
});
this.sparks = new THREE.Points(sparkGeom, this.sparkMat);
this.group.add(this.sparks);
}
update(dt: number): boolean {
if (this.done) return false;
this.t += dt;
const progress = Math.min(this.t / DURATION, 1);
const scale = 1 + progress * 0.3;
this.group.scale.set(scale, 1, scale);
this.outerMat.opacity = 0.5 * Math.pow(1 - progress, 1.5);
this.innerMat.opacity = 0.85 * Math.pow(1 - progress, 1.5);
this.sparkMat.opacity = Math.pow(1 - progress, 1.5);
for (let i = 0; i < SPARK_COUNT; i++) {
if (this.sparkLives[i] <= 0) continue;
this.sparkLives[i] -= dt;
this.sparkPositions[i * 3] += this.sparkVelocities[i * 3] * dt;
this.sparkPositions[i * 3 + 1] += this.sparkVelocities[i * 3 + 1] * dt;
this.sparkPositions[i * 3 + 2] += this.sparkVelocities[i * 3 + 2] * dt;
this.sparkVelocities[i * 3 + 1] -= 6 * dt;
}
const attr = this.sparks.geometry
.getAttribute('position') as THREE.BufferAttribute;
attr.needsUpdate = true;
if (progress >= 1) {
this.dispose();
return false;
}
return true;
}
dispose() {
if (this.done) return;
this.done = true;
this.group.removeFromParent();
for (const child of [...this.group.children]) {
if (child instanceof THREE.Mesh || child instanceof THREE.Points) {
child.geometry.dispose();
(child.material as THREE.Material).dispose();
}
}
}
}