diff --git a/web/index.html b/web/index.html index 60a3e6c..3883f4e 100644 --- a/web/index.html +++ b/web/index.html @@ -70,6 +70,38 @@ text-shadow: 4px 4px 8px #000; 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 { position: absolute; bottom: 16px; left: 50%; transform: translateX(-50%); color: rgba(255,255,255,0.5); font-size: 11px; font-family: monospace; @@ -108,6 +140,22 @@
Verloren!
+
+
+ + +
+ + +
WASD: Move · Space: Jump · Mouse: Aim · Left Click: Fireball · Right Click: Sword · M3: Swap hands · E: Teleport
diff --git a/web/src/Enemy.ts b/web/src/Enemy.ts index 112dab4..88b5dac 100644 --- a/web/src/Enemy.ts +++ b/web/src/Enemy.ts @@ -1,115 +1,29 @@ import * as THREE from 'three'; import type { Game } from './Game'; -import { playSound, 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'; +import { playSound } from './SoundManager'; -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 Enemy { +export abstract class Enemy { body: THREE.Group; dead = false; deathTimer = 0; knockback = 0; health = 100; + speed = 6; mixer: THREE.AnimationMixer | null = null; - private currentAnim = ''; - private clips: Map = new Map(); - private stopWalkSound: (() => void) | null = null; + protected clips: Map = new Map(); + protected currentAnim = ''; + protected stopWalkSound: (() => void) | null = null; - constructor(_playerPos: THREE.Vector3) { + constructor() { 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) { - 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(); - } + abstract playAnim(name: string, loop?: boolean, speed?: number): void; + abstract updateAnimation(dt: number): void; + abstract update(dt: number, game: Game): void; - updateAnimation(dt: number) { - if (this.mixer) this.mixer.update(dt); + protected onDeath() { + // Subclasses override for model-specific death visuals } takeDamage(damage: number, game: Game) { @@ -127,11 +41,18 @@ export class Enemy { private die(game: Game) { if (this.dead) return; this.dead = true; - this.playAnim('die', false); + this.onDeath(); if (this.stopWalkSound) { this.stopWalkSound(); this.stopWalkSound = null; } game.removeEnemy(this); } + + dispose() { + if (this.stopWalkSound) { + this.stopWalkSound(); + this.stopWalkSound = null; + } + } } diff --git a/web/src/Game.ts b/web/src/Game.ts index 5f1f457..66dd6a8 100644 --- a/web/src/Game.ts +++ b/web/src/Game.ts @@ -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 = new Set(); mouseButtons: Set = 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); + } } } diff --git a/web/src/Ghost.ts b/web/src/Ghost.ts new file mode 100644 index 0000000..c8cfc92 --- /dev/null +++ b/web/src/Ghost.ts @@ -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(); + } +} diff --git a/web/src/Player.ts b/web/src/Player.ts index 639ee76..ea3192f 100644 --- a/web/src/Player.ts +++ b/web/src/Player.ts @@ -172,4 +172,13 @@ export class Player { die() { 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'); + } } diff --git a/web/src/Spider.ts b/web/src/Spider.ts new file mode 100644 index 0000000..809e21b --- /dev/null +++ b/web/src/Spider.ts @@ -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); + } +} diff --git a/web/src/Sword.ts b/web/src/Sword.ts index 4c47797..c25a068 100644 --- a/web/src/Sword.ts +++ b/web/src/Sword.ts @@ -3,6 +3,7 @@ import { Weapon } from './Weapon'; import { Player } from './Player'; import { loadGLB } from './MeshLoader'; import { playSound } from './SoundManager'; +import { SlashEffect, isInSlashArc } from './SwordTrail'; export class Sword extends Weapon { private mixer: THREE.AnimationMixer | null = null; @@ -97,18 +98,24 @@ export class Sword extends Weapon { 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) { if (enemy.dead) continue; - const dist = enemy.body.position.distanceTo(player.body.position); - if (dist < 3) { - 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); - } + if (isInSlashArc(player.body.position, aim, enemy.body.position)) { + enemy.takeDamage(50, game); } } } diff --git a/web/src/SwordTrail.ts b/web/src/SwordTrail.ts new file mode 100644 index 0000000..64cb225 --- /dev/null +++ b/web/src/SwordTrail.ts @@ -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(); + } + } + } +}