Add main menu, ghost enemy, and sword slash trail
This commit is contained in:
@@ -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
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user