Add Minotaurus boss with front armor, dash stun and jumpable axe swing

This commit is contained in:
2026-08-19 14:41:02 +02:00
parent 7d41f6db6c
commit 1f67aaa982
5 changed files with 943 additions and 9 deletions
+4 -3
View File
@@ -34,8 +34,8 @@ npm run dev # Dev-Server mit HMR
| `Trapper.ts` | Falle (skill1) + Gaswolke (skill2) | | `Trapper.ts` | Falle (skill1) + Gaswolke (skill2) |
| `Aura.ts` / `Boomerang.ts` | Passive Skills, lesen Level live aus `game.skillSystem` | | `Aura.ts` / `Boomerang.ts` | Passive Skills, lesen Level live aus `game.skillSystem` |
| `Trap.ts` / `GasCloud.ts` | Platzierbare Effekte; `update(dt, game): boolean` (false = entfernen), `dispose()` | | `Trap.ts` / `GasCloud.ts` | Platzierbare Effekte; `update(dt, game): boolean` (false = entfernen), `dispose()` |
| `Golem.ts` / `Krake.ts` | Bosse (Welle 10 / 20), registriert in `Game.bossWaves` (`{name, spawn}`). Krake: stationär (`spawnAtCenter`), Tinte (`InkBlob.ts`/`InkPuddle.ts`: Slow + DoT), Tentakel-Griff (5 rote Boden-Marken um den Oktopus → Pull via `player.pullTime`), Sweep (roter Tentakel kreist um den Oktopus, überspringbar), Enrage <50% HP | | `Golem.ts` / `Krake.ts` / `Minotaurus.ts` | Bosse (Welle 10 / 20 / 30), registriert in `Game.bossWaves` (`{name, spawn}`). Krake: stationär (`spawnAtCenter`), Tinte (`InkBlob.ts`/`InkPuddle.ts`: Slow + DoT), Tentakel-Griff (5 rote Boden-Marken um den Oktopus → Pull via `player.pullTime`), Sweep (roter Tentakel kreist um den Oktopus, überspringbar), Enrage <30% HP. Minotaurus: nur von hinten verletzbar (vorn „BLOCK"), Turn-Lag, Dash → Wand-Stun (Sterne) + Wandrisse, Axt-Schwung (überspringbar), Sprung+Stampf (Stun `player.stunTime` + `FallingRock.ts`), Enrage <40% (Doppel-Dash, Kamera-Shake) |
| `InkBlob.ts` / `InkPuddle.ts` | Krake-Effekte: Tintenprojektil → Pfütze mit Slow (`player.slowTimer`) + DoT + `game.triggerInkVignette()` | | `InkBlob.ts` / `InkPuddle.ts` / `FallingRock.ts` | Boss-Effekte: Tintenprojektil → Pfütze mit Slow (`player.slowTimer`) + DoT + `game.triggerInkVignette()`; fallender Fels mit Schatten-Telegraf |
| `*Enemy*.ts` (Spider, Ghost, Frog, Bat, BeeSwarm, Slime, Brute, Crab, Turret) | Prozedurale Gegner-Modelle; je `update(dt, game)` | | `*Enemy*.ts` (Spider, Ghost, Frog, Bat, BeeSwarm, Slime, Brute, Crab, Turret) | Prozedurale Gegner-Modelle; je `update(dt, game)` |
| `SoundManager.ts` | Vorab geladene .ogg-Buffer (`playSound(name, volume)`); Sounds: damage, spawn, fireball, explosion, teleport, gameover, sword_hit1, spiderwalking | | `SoundManager.ts` | Vorab geladene .ogg-Buffer (`playSound(name, volume)`); Sounds: damage, spawn, fireball, explosion, teleport, gameover, sword_hit1, spiderwalking |
| `MeshLoader.ts` | `loadGLB(name)``web/public/models/*.glb`; Texturen in `web/public/textures/` | | `MeshLoader.ts` | `loadGLB(name)``web/public/models/*.glb`; Texturen in `web/public/textures/` |
@@ -50,6 +50,7 @@ npm run dev # Dev-Server mit HMR
- **Effekt-Lifecycle**: Neue Effekte mit eigenem `update(dt, game)`-Rückgabewert (false = aufräumen) und `dispose()`; in `Game.resetWorld()` ALLES aufräumen (sonst Geister-Objekte nach „Nochmal spielen"). - **Effekt-Lifecycle**: Neue Effekte mit eigenem `update(dt, game)`-Rückgabewert (false = aufräumen) und `dispose()`; in `Game.resetWorld()` ALLES aufräumen (sonst Geister-Objekte nach „Nochmal spielen").
- **Gegner-Festhalten**: `enemy.rootTime > 0` friert `enemy.update()` ein (siehe `Game.updateEnemies`) — so funktioniert die Falle, ohne jede Gegner-Klasse anzufassen. - **Gegner-Festhalten**: `enemy.rootTime > 0` friert `enemy.update()` ein (siehe `Game.updateEnemies`) — so funktioniert die Falle, ohne jede Gegner-Klasse anzufassen.
- **Boss-Wellen & Boss-Modus**: `Game.bossWaves` (Map: Welle → `{name, spawn}`) steuert Boss-Wellen (10, 20 …) — Boss-Welle startet erst, wenn die Arena geräumt ist (`enemies.every(e => e.dead)`); nach Boss-Tod läuft die nächste normale Welle an. Cheat-Code **„idkfa"** im Hauptmenü blendet den Boss-Modus ein (`#boss-mode`, Buttons werden aus `bossWaves` erzeugt) → `startBossFight(wave)` mit Zufalls-Skill-Boost (`grantRandomSkills`). `spawnAtCenter`-Flag lässt stationäre Bosse in der Arena-Mitte spawnen. Effekte, die ein Boss selbst verwaltet (Tinten-Blobs/-Pfützen, Ringe), müssen in `onDeath`/`dispose` aufgeräumt werden (Dead-Enemy-Removal ruft `dispose` NICHT auf). - **Boss-Wellen & Boss-Modus**: `Game.bossWaves` (Map: Welle → `{name, spawn}`) steuert Boss-Wellen (10, 20 …) — Boss-Welle startet erst, wenn die Arena geräumt ist (`enemies.every(e => e.dead)`); nach Boss-Tod läuft die nächste normale Welle an. Cheat-Code **„idkfa"** im Hauptmenü blendet den Boss-Modus ein (`#boss-mode`, Buttons werden aus `bossWaves` erzeugt) → `startBossFight(wave)` mit Zufalls-Skill-Boost (`grantRandomSkills`). `spawnAtCenter`-Flag lässt stationäre Bosse in der Arena-Mitte spawnen. Effekte, die ein Boss selbst verwaltet (Tinten-Blobs/-Pfützen, Ringe), müssen in `onDeath`/`dispose` aufgeräumt werden (Dead-Enemy-Removal ruft `dispose` NICHT auf).
- **Spieler-Status-Effekte** (in `Player.ts` + `Game.updatePlayerMovement`): `slowTimer`/`slowFactor` (Tinten-Slow, Lila-Tönung) und `pullTime`/`pullDir` (Tentakel-Griff: Eingabe wird ignoriert, Geschwindigkeit = Pull-Richtung). Beides wird in `Player.reset()` zurückgesetzt. - **Spieler-Status-Effekte** (in `Player.ts` + `Game.updatePlayerMovement`): `slowTimer`/`slowFactor` (Tinten-Slow, Lila-Tönung), `pullTime`/`pullDir` (Tentakel-Griff: Eingabe wird ignoriert, Geschwindigkeit = Pull-Richtung) und `stunTime` (Stampf-Schockwelle: keine Eingaben, gelbe Sterne kreisen um den Kopf). Alles wird in `Player.reset()` zurückgesetzt.
- **Kamera-Shake**: `game.triggerCameraShake(duration, amp)` → in `animate()` wird die Kameraposition kurz zufällig versetzt (kein Postprocessing nötig).
- **Kontaktschaden** (Gegner berührt Spieler) läuft in `Game.ts` (Close-Encounter-Loop) mit `contactDps * dt`; Dornen-Skill ebendort als Gegenschlag. - **Kontaktschaden** (Gegner berührt Spieler) läuft in `Game.ts` (Close-Encounter-Loop) mit `contactDps * dt`; Dornen-Skill ebendort als Gegenschlag.
- **`playSound`** erzeugt pro Aufruf einen AudioBufferSourceNode — bei hochfrequenten Effekten (Aura-Ticks, Bumerang-Hits) ggf. throtteln. - **`playSound`** erzeugt pro Aufruf einen AudioBufferSourceNode — bei hochfrequenten Effekten (Aura-Ticks, Bumerang-Hits) ggf. throtteln.
+112
View File
@@ -0,0 +1,112 @@
import * as THREE from 'three';
import type { Game } from './Game';
const TELEGRAPH_TIME = 0.9;
const IMPACT_RADIUS = 1.8;
const ROCK_DAMAGE = 30;
const DUST_TIME = 0.4;
export class FallingRock {
body: THREE.Group;
private shadow: THREE.Mesh;
private rock: THREE.Mesh;
private phase: 'telegraph' | 'fall' | 'dust' = 'telegraph';
private timer = TELEGRAPH_TIME;
private dust: THREE.Mesh[] = [];
private dustDirs: THREE.Vector3[] = [];
private dustMat: THREE.MeshBasicMaterial;
constructor(pos: THREE.Vector3) {
this.body = new THREE.Group();
this.body.position.set(pos.x, 0, pos.z);
// Schatten-Telegraf (waechst kurz vor dem Aufprall)
const shadowMat = new THREE.MeshBasicMaterial({
color: 0x000000,
transparent: true,
opacity: 0.3,
depthWrite: false,
});
this.shadow = new THREE.Mesh(new THREE.CircleGeometry(1.6, 24), shadowMat);
this.shadow.rotation.x = -Math.PI / 2;
this.shadow.position.y = 0.06;
this.shadow.scale.setScalar(0.4);
this.body.add(this.shadow);
// Fels von der Decke
const rockMat = new THREE.MeshToonMaterial({ color: 0x777788 });
this.rock = new THREE.Mesh(new THREE.BoxGeometry(0.9, 0.9, 0.9), rockMat);
this.rock.position.y = 16;
this.rock.rotation.set(Math.random() * 3, Math.random() * 3, Math.random() * 3);
this.body.add(this.rock);
this.dustMat = new THREE.MeshBasicMaterial({
color: 0x888899,
transparent: true,
opacity: 0,
depthWrite: false,
});
}
update(dt: number, game: Game): boolean {
this.timer -= dt;
if (this.phase === 'telegraph') {
const t = 1 - this.timer / TELEGRAPH_TIME;
this.shadow.scale.setScalar(0.4 + t * 0.6);
(this.shadow.material as THREE.MeshBasicMaterial).opacity = 0.2 + t * 0.2;
if (this.timer <= 0) {
this.phase = 'fall';
this.timer = 2;
}
} else if (this.phase === 'fall') {
this.rock.position.y -= 26 * dt;
this.rock.rotation.x += dt * 3;
this.rock.rotation.z += dt * 2;
if (this.rock.position.y <= 0.15) {
this.rock.position.y = 0.15;
this.rock.visible = false;
this.shadow.visible = false;
if (game.player.health > 0) {
const dx = game.player.body.position.x - this.body.position.x;
const dz = game.player.body.position.z - this.body.position.z;
if (Math.hypot(dx, dz) < IMPACT_RADIUS) {
game.player.damage(ROCK_DAMAGE, this.body.position);
}
}
// Staubwolke
for (let i = 0; i < 5; i++) {
const a = (i / 5) * Math.PI * 2;
const dir = new THREE.Vector3(Math.cos(a), 0, Math.sin(a));
const puff = new THREE.Mesh(new THREE.SphereGeometry(0.25, 6, 5), this.dustMat);
this.body.add(puff);
this.dust.push(puff);
this.dustDirs.push(dir);
}
this.phase = 'dust';
this.timer = DUST_TIME;
}
} else {
const t = 1 - this.timer / DUST_TIME;
for (let i = 0; i < this.dust.length; i++) {
this.dust[i].position.copy(this.dustDirs[i]).multiplyScalar(0.5 + t * 1.8);
this.dust[i].position.y = 0.2 + t * 0.8;
}
this.dustMat.opacity = 0.5 * (1 - t);
if (this.timer <= 0) return false;
}
return true;
}
dispose() {
this.body.removeFromParent();
this.body.traverse((obj) => {
if (obj instanceof THREE.Mesh) {
obj.geometry.dispose();
(obj.material as THREE.Material).dispose();
}
});
}
}
+30 -6
View File
@@ -10,6 +10,7 @@ import { Slime } from './Slime';
import { Brute } from './Brute'; import { Brute } from './Brute';
import { Golem } from './Golem'; import { Golem } from './Golem';
import { Krake } from './Krake'; import { Krake } from './Krake';
import { Minotaurus } from './Minotaurus';
import { Crab } from './Crab'; import { Crab } from './Crab';
import { Turret } from './Turret'; import { Turret } from './Turret';
import { EnemyProjectile } from './EnemyProjectile'; import { EnemyProjectile } from './EnemyProjectile';
@@ -81,8 +82,11 @@ export class Game {
private bossWaves = new Map<number, { name: string; spawn: () => Enemy }>([ private bossWaves = new Map<number, { name: string; spawn: () => Enemy }>([
[10, { name: 'Golem', spawn: () => new Golem() }], [10, { name: 'Golem', spawn: () => new Golem() }],
[20, { name: 'Krake', spawn: () => new Krake() }], [20, { name: 'Krake', spawn: () => new Krake() }],
[30, { name: 'Minotaurus', spawn: () => new Minotaurus() }],
]); ]);
private bossFightActive = false; private bossFightActive = false;
private shakeTime = 0;
private shakeAmp = 0;
// Debug hitzone visualization // Debug hitzone visualization
private showHitzones = false; private showHitzones = false;
@@ -298,7 +302,8 @@ export class Game {
e.preventDefault(); e.preventDefault();
return; return;
} }
if (e.code === 'Space' && this.state === 'playing' && this.player.pullTime <= 0) { if (e.code === 'Space' && this.state === 'playing'
&& this.player.pullTime <= 0 && this.player.stunTime <= 0) {
this.player.jump(); this.player.jump();
e.preventDefault(); e.preventDefault();
} }
@@ -728,6 +733,7 @@ export class Game {
this.spawns = 0; this.spawns = 0;
this.killed = 0; this.killed = 0;
this.bossFightActive = false; this.bossFightActive = false;
this.shakeTime = 0;
this.pendingPicks = 0; this.pendingPicks = 0;
this.hideLevelUp(); this.hideLevelUp();
this.skillSystem = new SkillSystem(this.seed ?? 0); this.skillSystem = new SkillSystem(this.seed ?? 0);
@@ -754,15 +760,24 @@ export class Game {
} }
// Treffer-Feedback: Schadenszahl bei Bossen einblenden // Treffer-Feedback: Schadenszahl bei Bossen einblenden
if (target?.isBoss && !target.dead) { if (target?.isBoss && !target.dead) {
this.spawnDamageNumber(Math.round(damage), target.body.position); this.spawnDamageText(`${Math.round(damage)}`, target.body.position);
} }
} }
spawnDamageNumber(damage: number, pos: THREE.Vector3, color?: string) { spawnDamageText(text: string, pos: THREE.Vector3, color?: string) {
const num = new DamageNumber(this.scene, `${damage}`, pos, color); const num = new DamageNumber(this.scene, text, pos, color);
this.damageNumbers.push(num); this.damageNumbers.push(num);
} }
spawnDamageNumber(damage: number, pos: THREE.Vector3, color?: string) {
this.spawnDamageText(`${damage}`, pos, color);
}
triggerCameraShake(duration: number, amp: number) {
this.shakeTime = duration;
this.shakeAmp = amp;
}
private openLevelUp() { private openLevelUp() {
this.offers = this.skillSystem.rollOffers(); this.offers = this.skillSystem.rollOffers();
if (this.offers.length === 0) { if (this.offers.length === 0) {
@@ -1012,7 +1027,10 @@ export class Game {
if (p.slowTimer > 0) speed *= p.slowFactor; if (p.slowTimer > 0) speed *= p.slowFactor;
let isMoving = false; let isMoving = false;
if (p.pullTime > 0) { if (p.stunTime > 0) {
// Gestunnt (Stampf-Schockwelle): keine Eingaben
p.velocity.set(0, 0, 0);
} else if (p.pullTime > 0) {
// Tentakel-Griff: Spieler wird komplett zum Boss gezogen // Tentakel-Griff: Spieler wird komplett zum Boss gezogen
p.pullTime -= dt; p.pullTime -= dt;
p.velocity.copy(p.pullDir).multiplyScalar(20); p.velocity.copy(p.pullDir).multiplyScalar(20);
@@ -1042,7 +1060,7 @@ export class Game {
} }
// Jump / gravity // Jump / gravity
if (!p.onGround && p.pullTime <= 0) { if (!p.onGround && p.pullTime <= 0 && p.stunTime <= 0) {
p.jumpVelocity -= 20 * dt; p.jumpVelocity -= 20 * dt;
p.body.position.y += p.jumpVelocity * dt; p.body.position.y += p.jumpVelocity * dt;
if (p.body.position.y <= 0.5) { if (p.body.position.y <= 0.5) {
@@ -1430,6 +1448,12 @@ export class Game {
25, 25,
this.player.body.position.z - 15 this.player.body.position.z - 15
); );
if (this.shakeTime > 0) {
this.shakeTime -= dt;
const k = this.shakeTime * this.shakeAmp;
this.camera.position.x += (Math.random() - 0.5) * k;
this.camera.position.z += (Math.random() - 0.5) * k;
}
this.camera.lookAt( this.camera.lookAt(
this.player.body.position.x, this.player.body.position.x,
0, 0,
+769
View File
@@ -0,0 +1,769 @@
import * as THREE from 'three';
import type { Game } from './Game';
import { Enemy } from './Enemy';
import { playSound } from './SoundManager';
import { FallingRock } from './FallingRock';
const MAX_HEALTH = 6000;
const HIT_FLASH_TIME = 0.14;
const MODEL_SCALE = 1.7;
const IDEAL_DIST = 4.5;
const MIN_DIST = 3.2;
const MOVE_SPEED = 5;
const TURN_SPEED_1 = 2.2;
const TURN_SPEED_2 = 3.5;
const DASH_COOLDOWN = 8;
const DASH_COOLDOWN_2 = 4;
const DASH_WINDUP = 0.6;
const DASH_SPEED = 34;
const DASH_MAX_DIST = 30;
const DASH_DAMAGE = 35;
const WALL_LIMIT = 46;
const BOSS_STUN_TIME = 3.5;
const SWING_COOLDOWN = 5;
const SWING_COOLDOWN_2 = 3.5;
const SWING_WINDUP = 0.45;
const SWING_TIME = 0.3;
const SWING_RANGE = 5.2;
const SWING_ARC = (90 * Math.PI) / 180;
const SWING_DAMAGE = 60;
const STOMP_COOLDOWN = 10;
const STOMP_COOLDOWN_2 = 8;
const STOMP_JUMP_TIME = 1.0;
const STOMP_JUMP_HEIGHT = 4;
const STOMP_RADIUS = 11;
const STOMP_DAMAGE = 15;
const STOMP_STUN = 1.2;
const ENRAGE_RATIO = 0.4;
const ROCK_COUNT = 7;
const ROCK_COUNT_2 = 10;
type State = 'idle' | 'dashwindup' | 'dash' | 'dashstop' | 'stunned' | 'swingwindup' | 'swing' | 'jump';
export class Minotaurus extends Enemy {
private model: THREE.Group;
private game: Game | null = null;
private waveTime = 0;
private state: State = 'idle';
private stateTime = 0;
private yaw = 0;
private enraged = false;
private hitFlash = 0;
private dashTimer = 3.5;
private swingTimer = 2;
private stompTimer = 6;
private doubleDashLeft = 0;
private dashDir = new THREE.Vector3();
private dashDist = 0;
private dashHitPlayer = false;
private swingHit = false;
private jumpFrom = new THREE.Vector3();
private jumpTo = new THREE.Vector3();
private axeGroup: THREE.Group;
private eyeMat!: THREE.MeshBasicMaterial;
private furMat!: THREE.MeshToonMaterial;
private darkMat!: THREE.MeshToonMaterial;
private starsGroup: THREE.Group;
private telegraphArc!: THREE.Mesh;
private swingArc!: THREE.Mesh;
private dashLine!: THREE.Mesh;
private shockRing!: THREE.Mesh;
private shockRingMat!: THREE.MeshBasicMaterial;
private legs: THREE.Group[] = [];
private moving = false;
private rocks: FallingRock[] = [];
private cracks: { group: THREE.Group; life: number }[] = [];
constructor() {
super();
this.health = MAX_HEALTH;
this.maxHealth = MAX_HEALTH;
this.isBoss = true;
this.displayName = 'Minotaurus';
this.speed = 0;
this.xp = 200;
this.contactDps = 0;
this.contactRadius = 0;
this.guaranteedDrop = true;
this.model = new THREE.Group();
this.model.scale.setScalar(MODEL_SCALE);
// Kein fadeOutModel: beim Tod schrumpft der ganze Body inkl. Effekten
this.body.add(this.model);
this.furMat = new THREE.MeshToonMaterial({ color: 0x6b4a2f });
this.darkMat = new THREE.MeshToonMaterial({ color: 0x4a3220 });
const hornMat = new THREE.MeshToonMaterial({ color: 0xd8c8a8 });
const axeMat = new THREE.MeshToonMaterial({ color: 0x9999aa });
const metalMat = new THREE.MeshToonMaterial({ color: 0x8a8a99 });
const darkMetalMat = new THREE.MeshToonMaterial({ color: 0x555566 });
this.eyeMat = new THREE.MeshBasicMaterial({ color: 0xffcc33 });
// Beine (Gruppen mit Hueft-Gelenk, laufen animiert)
for (const side of [-1, 1]) {
const legGroup = new THREE.Group();
legGroup.position.set(side * 0.45, 0.75, 0);
const leg = new THREE.Mesh(new THREE.CylinderGeometry(0.24, 0.3, 0.8, 10), this.darkMat);
leg.position.y = -0.4;
leg.castShadow = true;
this.registerFadeMesh(leg);
legGroup.add(leg);
const hoof = new THREE.Mesh(new THREE.BoxGeometry(0.3, 0.16, 0.42), metalMat);
hoof.position.set(0, -0.78, 0.06);
this.registerFadeMesh(hoof);
legGroup.add(hoof);
const guard = new THREE.Mesh(new THREE.BoxGeometry(0.26, 0.4, 0.1), metalMat);
guard.position.set(0, -0.4, 0.2);
this.registerFadeMesh(guard);
legGroup.add(guard);
this.model.add(legGroup);
this.legs.push(legGroup);
}
// Torso
const torso = new THREE.Mesh(new THREE.BoxGeometry(1.4, 1.3, 1.0), this.furMat);
torso.position.y = 1.15;
torso.castShadow = true;
this.registerFadeMesh(torso);
this.model.add(torso);
// Vorder-Panzerung: Brustplatte
const chest = new THREE.Mesh(new THREE.BoxGeometry(1.1, 0.8, 0.1), metalMat);
chest.position.set(0, 1.18, 0.48);
chest.castShadow = true;
this.registerFadeMesh(chest);
this.model.add(chest);
// Schultern (gepanzert vorn)
for (const side of [-1, 1]) {
const shoulder = new THREE.Mesh(new THREE.SphereGeometry(0.42, 12, 10), this.furMat);
shoulder.position.set(side * 0.85, 1.6, 0);
shoulder.castShadow = true;
this.registerFadeMesh(shoulder);
this.model.add(shoulder);
const pauldron = new THREE.Mesh(new THREE.BoxGeometry(0.6, 0.3, 0.4), metalMat);
pauldron.position.set(side * 0.85, 1.68, 0.3);
this.registerFadeMesh(pauldron);
this.model.add(pauldron);
}
// Brustpanzer-Gürtel
const belt = new THREE.Mesh(new THREE.BoxGeometry(1.2, 0.16, 0.75), darkMetalMat);
belt.position.set(0, 0.68, 0);
this.registerFadeMesh(belt);
this.model.add(belt);
// Linker Arm
const arm = new THREE.Mesh(new THREE.CylinderGeometry(0.2, 0.26, 0.9, 8), this.furMat);
arm.position.set(-0.85, 0.85, 0);
arm.castShadow = true;
this.registerFadeMesh(arm);
this.model.add(arm);
// Rechter Arm mit Axt (schwingt beim Angriff)
this.axeGroup = new THREE.Group();
this.axeGroup.position.set(0.85, 1.0, 0);
this.model.add(this.axeGroup);
const axeArm = new THREE.Mesh(new THREE.CylinderGeometry(0.2, 0.26, 0.9, 8), this.furMat);
axeArm.position.y = -0.15;
axeArm.castShadow = true;
this.registerFadeMesh(axeArm);
this.axeGroup.add(axeArm);
const handle = new THREE.Mesh(new THREE.CylinderGeometry(0.08, 0.08, 1.8, 8), this.darkMat);
handle.rotation.x = Math.PI / 2;
handle.position.z = 0.6;
handle.castShadow = true;
this.registerFadeMesh(handle);
this.axeGroup.add(handle);
const blade = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.7, 0.12), axeMat);
blade.position.z = 1.35;
blade.castShadow = true;
this.registerFadeMesh(blade);
this.axeGroup.add(blade);
// Kopf
const head = new THREE.Mesh(new THREE.BoxGeometry(0.7, 0.6, 0.75), this.furMat);
head.position.y = 2.05;
head.castShadow = true;
this.registerFadeMesh(head);
this.model.add(head);
// Schnauze (+Z)
const snout = new THREE.Mesh(new THREE.BoxGeometry(0.34, 0.24, 0.4), this.darkMat);
snout.position.set(0, 1.92, 0.55);
this.registerFadeMesh(snout);
this.model.add(snout);
// Hörner
for (const side of [-1, 1]) {
const horn = new THREE.Mesh(new THREE.ConeGeometry(0.09, 0.55, 8), hornMat);
horn.position.set(side * 0.28, 2.35, 0.1);
horn.rotation.set(-1.1, 0, side * 0.45);
horn.castShadow = true;
this.registerFadeMesh(horn);
this.model.add(horn);
}
// Augen (+Z, gelb -> rot in Phase 2)
for (const side of [-1, 1]) {
const eye = new THREE.Mesh(new THREE.SphereGeometry(0.09, 8, 6), this.eyeMat);
eye.position.set(side * 0.2, 2.12, 0.4);
this.registerFadeMesh(eye);
this.model.add(eye);
}
// Stirnpanzer vorn
const brow = new THREE.Mesh(new THREE.BoxGeometry(0.6, 0.22, 0.08), metalMat);
brow.position.set(0, 2.24, 0.38);
this.registerFadeMesh(brow);
this.model.add(brow);
// --- Effekte an den Body haengen (nicht skaliert, drehen aber mit) ---
// Stun-Sterne um den Kopf
this.starsGroup = new THREE.Group();
const starGeom = new THREE.OctahedronGeometry(0.14);
const starMat = new THREE.MeshBasicMaterial({ color: 0xffdd33 });
for (let i = 0; i < 4; i++) {
const a = (i / 4) * Math.PI * 2;
const star = new THREE.Mesh(starGeom, starMat);
star.position.set(Math.cos(a) * 1.4, 3.7, Math.sin(a) * 1.4);
this.starsGroup.add(star);
}
this.starsGroup.visible = false;
this.body.add(this.starsGroup);
// Axt-Telegraf-Bogen (Trefferzone) auf dem Boden.
// Achtung: RingGeometry-Winkel starten an lokaler +X -> um 90° versetzen,
// damit der Halbkreis nach vorn (+Z, Blickrichtung) zeigt.
const arcMat = new THREE.MeshBasicMaterial({
color: 0xff4444,
transparent: true,
opacity: 0,
side: THREE.DoubleSide,
depthWrite: false,
});
const arcGeom = new THREE.RingGeometry(
SWING_RANGE - 0.7, SWING_RANGE - 0.2, 48, 1,
-SWING_ARC - Math.PI / 2, SWING_ARC * 2
);
this.telegraphArc = new THREE.Mesh(arcGeom, arcMat);
this.telegraphArc.rotation.x = -Math.PI / 2;
this.telegraphArc.position.y = 0.05;
this.telegraphArc.visible = false;
this.body.add(this.telegraphArc);
// Axt-Sweep-Visual: heller Bogen fegt von rechts nach links ueber den Boden
const swingArcMat = new THREE.MeshBasicMaterial({
color: 0xffaa88,
transparent: true,
opacity: 0,
side: THREE.DoubleSide,
depthWrite: false,
});
this.swingArc = new THREE.Mesh(arcGeom, swingArcMat);
this.swingArc.rotation.x = -Math.PI / 2;
this.swingArc.position.y = 0.05;
this.swingArc.visible = false;
this.body.add(this.swingArc);
// Dash-Richtungslinie
const lineMat = new THREE.MeshBasicMaterial({
color: 0xff3333,
transparent: true,
opacity: 0,
depthWrite: false,
});
this.dashLine = new THREE.Mesh(new THREE.BoxGeometry(0.14, 0.02, 12), lineMat);
this.dashLine.position.y = 0.06;
this.dashLine.position.z = 8;
this.dashLine.visible = false;
this.body.add(this.dashLine);
// Schockwellen-Ring
this.shockRingMat = new THREE.MeshBasicMaterial({
color: 0xff5533,
transparent: true,
opacity: 0,
side: THREE.DoubleSide,
depthWrite: false,
});
this.shockRing = new THREE.Mesh(new THREE.RingGeometry(0.9, 1.0, 48), this.shockRingMat);
this.shockRing.rotation.x = -Math.PI / 2;
this.shockRing.position.y = 0.06;
this.shockRing.visible = false;
this.body.add(this.shockRing);
}
playAnim() {
// Prozedurale Optik
}
updateAnimation(dt: number) {
this.updateFadeDeath(dt, { duration: 1.2, sink: 0.5 });
}
update(dt: number, game: Game) {
this.game = game;
this.waveTime += dt;
// Hit-Feedback: kurz weiss aufblitzen + Puls
if (this.hitFlash > 0) {
this.hitFlash -= dt;
this.furMat.color.set(0xffffff);
this.darkMat.color.set(0xffffff);
this.model.scale.setScalar(MODEL_SCALE * (1 + (this.hitFlash / HIT_FLASH_TIME) * 0.05));
} else {
this.furMat.color.set(0x6b4a2f);
this.darkMat.color.set(0x4a3220);
this.model.scale.setScalar(MODEL_SCALE);
}
// Phase 2 ab 40% HP
if (!this.enraged && this.health < MAX_HEALTH * ENRAGE_RATIO) {
this.enraged = true;
this.eyeMat.color.set(0xff2222);
playSound('damage', 0.9);
game.triggerCameraShake(0.35, 0.5);
}
switch (this.state) {
case 'idle': this.updateIdle(dt, game); break;
case 'dashwindup': this.updateDashWindup(dt, game); break;
case 'dash': this.updateDash(dt, game); break;
case 'dashstop': this.updateDashStop(dt); break;
case 'stunned': this.updateStunned(dt); break;
case 'swingwindup': this.updateSwingWindup(dt, game); break;
case 'swing': this.updateSwing(dt, game); break;
case 'jump': this.updateJump(dt, game); break;
}
// Stun-Sterne animieren
this.starsGroup.visible = this.state === 'stunned';
if (this.starsGroup.visible) {
this.starsGroup.rotation.y += dt * 3;
for (const star of this.starsGroup.children) {
star.rotation.x += dt * 5;
star.rotation.y += dt * 4;
star.position.y = 3.7 + Math.sin(this.waveTime * 6) * 0.1;
}
}
// Bein-Animation: Laufen / Dash-Haltung / sonst still
if (this.state === 'dash' || this.state === 'dashwindup') {
for (const leg of this.legs) leg.rotation.x = 0.5;
} else if (this.state === 'jump') {
for (const leg of this.legs) leg.rotation.x = 0.25;
} else if (this.state === 'idle' && this.moving) {
const step = Math.sin(this.waveTime * 9);
this.legs[0].rotation.x = step * 0.55;
this.legs[1].rotation.x = -step * 0.55;
} else {
for (const leg of this.legs) leg.rotation.x = 0;
}
// Schockwellen-Ring expandieren
if (this.shockRing.visible) {
const s = this.shockRing.scale.x + dt * 26;
this.shockRing.scale.setScalar(s);
this.shockRingMat.opacity = Math.max(0, 0.5 - (s / 11) * 0.5);
if (s >= 11) this.shockRing.visible = false;
}
// Felsen
for (let i = this.rocks.length - 1; i >= 0; i--) {
if (!this.rocks[i].update(dt, game)) {
this.rocks[i].dispose();
this.rocks.splice(i, 1);
}
}
// Wandrisse verblassen
for (let i = this.cracks.length - 1; i >= 0; i--) {
const c = this.cracks[i];
c.life -= dt;
const op = Math.max(0, c.life / 2.5) * 0.6;
c.group.traverse((obj) => {
if (obj instanceof THREE.Mesh) {
(obj.material as THREE.Material & { opacity: number }).opacity = op;
}
});
if (c.life <= 0) {
game.scene.remove(c.group);
this.disposeGroup(c.group);
this.cracks.splice(i, 1);
}
}
}
private updateIdle(dt: number, game: Game) {
const playerPos = game.player.body.position;
const dx = playerPos.x - this.body.position.x;
const dz = playerPos.z - this.body.position.z;
const dist = Math.hypot(dx, dz);
// Turn-Lag: langsam zum Spieler drehen
const target = Math.atan2(dx, dz);
const turnSpeed = this.enraged ? TURN_SPEED_2 : TURN_SPEED_1;
this.yaw = this.lerpAngle(this.yaw, target, Math.min(1, turnSpeed * dt));
this.body.rotation.y = this.yaw;
// Abstand halten: nie zu nah, nie zu weit
this.moving = false;
const moveDir = new THREE.Vector3();
if (dist > IDEAL_DIST + 1 && dist > 0.01) {
moveDir.set(dx / dist, 0, dz / dist);
this.moving = true;
} else if (dist < MIN_DIST && dist > 0.01) {
moveDir.set(-dx / dist, 0, -dz / dist);
this.moving = true;
}
if (moveDir.lengthSq() > 0) {
this.body.position.x += moveDir.x * MOVE_SPEED * dt;
this.body.position.z += moveDir.z * MOVE_SPEED * dt;
game.clampToArena(this.body.position);
}
this.dashTimer -= dt;
this.swingTimer -= dt;
this.stompTimer -= dt;
if (this.dashTimer <= 0) {
this.dashTimer = this.enraged ? DASH_COOLDOWN_2 : DASH_COOLDOWN;
this.doubleDashLeft = this.enraged ? 1 : 0;
this.startDashWindup(game);
} else if (this.swingTimer <= 0) {
this.swingTimer = this.enraged ? SWING_COOLDOWN_2 : SWING_COOLDOWN;
this.startSwingWindup();
} else if (this.stompTimer <= 0) {
this.stompTimer = this.enraged ? STOMP_COOLDOWN_2 : STOMP_COOLDOWN;
this.startJump(game);
}
}
// --- Dash ---
private startDashWindup(game: Game) {
this.state = 'dashwindup';
this.stateTime = DASH_WINDUP;
this.dashDir.set(
game.player.body.position.x - this.body.position.x,
0,
game.player.body.position.z - this.body.position.z
);
if (this.dashDir.lengthSq() < 0.01) this.dashDir.set(0, 0, 1);
this.dashDir.normalize();
this.dashHitPlayer = false;
this.yaw = Math.atan2(this.dashDir.x, this.dashDir.z);
this.body.rotation.y = this.yaw;
this.dashLine.visible = true;
playSound('spawn', 0.6);
}
private updateDashWindup(dt: number, game: Game) {
this.stateTime -= dt;
(this.dashLine.material as THREE.MeshBasicMaterial).opacity =
0.3 + 0.4 * Math.abs(Math.sin(this.waveTime * 14));
if (this.stateTime <= 0) {
this.dashLine.visible = false;
this.state = 'dash';
this.dashDist = 0;
playSound('spawn', 0.8);
}
}
private updateDash(dt: number, game: Game) {
const step = DASH_SPEED * dt;
this.dashDist += step;
this.body.position.x += this.dashDir.x * step;
this.body.position.z += this.dashDir.z * step;
if (!this.dashHitPlayer && game.player.health > 0) {
const dx = game.player.body.position.x - this.body.position.x;
const dz = game.player.body.position.z - this.body.position.z;
if (Math.hypot(dx, dz) < 1.7) {
this.dashHitPlayer = true;
game.player.damage(DASH_DAMAGE, this.body.position);
playSound('damage', 0.9);
}
}
if (Math.abs(this.body.position.x) > WALL_LIMIT || Math.abs(this.body.position.z) > WALL_LIMIT) {
game.clampToArena(this.body.position);
this.hitWall(game);
return;
}
if (this.dashDist >= DASH_MAX_DIST) {
this.endDash(game);
}
}
private endDash(game: Game) {
if (this.doubleDashLeft > 0) {
this.doubleDashLeft--;
this.startDashWindup(game);
} else {
this.state = 'dashstop';
this.stateTime = 0.3;
}
}
private updateDashStop(dt: number) {
this.stateTime -= dt;
if (this.stateTime <= 0) this.state = 'idle';
}
private hitWall(game: Game) {
this.state = 'stunned';
this.stateTime = BOSS_STUN_TIME;
this.spawnWallCracks(game);
playSound('explosion', 0.5);
}
private updateStunned(dt: number) {
this.stateTime -= dt;
// Schwindel-Wackeln
this.model.rotation.z = Math.sin(this.waveTime * 20) * 0.08;
if (this.stateTime <= 0) {
this.model.rotation.z = 0;
this.state = 'idle';
}
}
private spawnWallCracks(game: Game) {
const group = new THREE.Group();
const crackMat = new THREE.MeshBasicMaterial({
color: 0x111111,
transparent: true,
opacity: 0.6,
depthWrite: false,
});
const onXWall = Math.abs(this.body.position.x) > Math.abs(this.body.position.z);
for (let i = 0; i < 4; i++) {
const len = 0.8 + Math.random() * 1.2;
const geo = new THREE.BoxGeometry(
onXWall ? 0.06 : len,
0.06,
onXWall ? len : 0.06
);
const crack = new THREE.Mesh(geo, crackMat);
if (onXWall) {
crack.position.set(
Math.sign(this.body.position.x) * 49.2,
0.5 + Math.random() * 1.4,
this.body.position.z + (Math.random() - 0.5) * 2.4
);
} else {
crack.position.set(
this.body.position.x + (Math.random() - 0.5) * 2.4,
0.5 + Math.random() * 1.4,
Math.sign(this.body.position.z) * 49.2
);
}
crack.rotation.y = Math.random() * Math.PI;
group.add(crack);
}
game.scene.add(group);
this.cracks.push({ group, life: 2.5 });
}
// --- Axt-Schwung ---
private startSwingWindup() {
this.state = 'swingwindup';
this.stateTime = SWING_WINDUP;
this.swingHit = false;
// Axt anheben (leicht), bereit fuer den horizontalen Sweep
this.axeGroup.rotation.x = -0.35;
this.axeGroup.rotation.y = 1.0;
this.telegraphArc.visible = true;
playSound('spawn', 0.5);
}
private updateSwingWindup(dt: number, game: Game) {
this.stateTime -= dt;
(this.telegraphArc.material as THREE.MeshBasicMaterial).opacity =
0.3 + 0.4 * Math.abs(Math.sin(this.waveTime * 12));
if (this.stateTime <= 0) {
this.telegraphArc.visible = false;
this.state = 'swing';
this.stateTime = SWING_TIME;
}
}
private updateSwing(dt: number, game: Game) {
this.stateTime -= dt;
const t = 1 - this.stateTime / SWING_TIME;
// Axt fegt horizontal von rechts nach links vor dem Koerper
this.axeGroup.rotation.y = 1.0 - t * 2.0;
this.axeGroup.rotation.x = -0.35 + t * 0.35;
// Hell: Halbkreis fegt ueber den Boden
this.swingArc.visible = true;
this.swingArc.rotation.y = SWING_ARC * 0.85 * (1 - 2 * t);
const fade = Math.min(1, t * 6, (1 - t) * 6);
(this.swingArc.material as THREE.MeshBasicMaterial).opacity = fade * 0.7;
// Treffer in der Mitte des Schwungs
if (!this.swingHit && t >= 0.35) {
this.swingHit = true;
const playerPos = game.player.body.position;
const dx = playerPos.x - this.body.position.x;
const dz = playerPos.z - this.body.position.z;
const dist = Math.hypot(dx, dz);
if (dist < SWING_RANGE) {
const ang = Math.atan2(dx, dz);
// Ueberspringbar: Treffer nur am Boden
if (Math.abs(this.angleDiff(this.yaw, ang)) < SWING_ARC && playerPos.y <= 0.6) {
game.player.damage(SWING_DAMAGE, this.body.position);
playSound('damage', 0.9);
}
}
}
if (this.stateTime <= 0) {
this.axeGroup.rotation.x = 0;
this.axeGroup.rotation.y = 0;
this.swingArc.visible = false;
this.state = 'idle';
}
}
// --- Sprung + Stampf ---
private startJump(game: Game) {
this.state = 'jump';
this.stateTime = STOMP_JUMP_TIME;
this.jumpFrom.copy(this.body.position);
this.jumpTo.set(game.player.body.position.x, 0, game.player.body.position.z);
playSound('spawn', 0.7);
}
private updateJump(dt: number, game: Game) {
this.stateTime -= dt;
const t = 1 - Math.max(0, this.stateTime) / STOMP_JUMP_TIME;
this.body.position.x = this.jumpFrom.x + (this.jumpTo.x - this.jumpFrom.x) * t;
this.body.position.z = this.jumpFrom.z + (this.jumpTo.z - this.jumpFrom.z) * t;
this.body.position.y = 0.15 + Math.sin(t * Math.PI) * STOMP_JUMP_HEIGHT;
if (this.stateTime <= 0) {
this.body.position.y = 0.15;
this.landStomp(game);
}
}
private landStomp(game: Game) {
// Schockwelle: stunnt den Spieler (ueberspringbar)
this.shockRing.visible = true;
this.shockRing.scale.setScalar(0.2);
this.shockRingMat.opacity = 0.5;
playSound('explosion', 0.7);
const playerPos = game.player.body.position;
const dist = Math.hypot(
playerPos.x - this.body.position.x,
playerPos.z - this.body.position.z
);
if (dist < STOMP_RADIUS && playerPos.y <= 0.6) {
game.player.stunTime = STOMP_STUN;
game.player.damage(STOMP_DAMAGE, this.body.position);
}
// Steinschlag um den Spieler
const count = this.enraged ? ROCK_COUNT_2 : ROCK_COUNT;
for (let i = 0; i < count; i++) {
const a = Math.random() * Math.PI * 2;
const r = 2 + Math.random() * 6;
const rock = new FallingRock(new THREE.Vector3(
playerPos.x + Math.cos(a) * r,
0,
playerPos.z + Math.sin(a) * r
));
game.scene.add(rock.body);
this.rocks.push(rock);
}
this.state = 'idle';
}
// --- Schaden ---
override takeDamage(
damage: number,
game: Game,
withKnockback = true,
sourcePos?: THREE.Vector3
) {
const src = sourcePos ?? game.player.body.position;
const fwd = new THREE.Vector3(Math.sin(this.yaw), 0, Math.cos(this.yaw));
const incoming = new THREE.Vector3().subVectors(src, this.body.position);
incoming.y = 0;
if (incoming.lengthSq() < 0.0001) incoming.copy(fwd);
incoming.normalize();
// Nur von hinten verletzbar: vorn wird geblockt
if (fwd.dot(incoming) > 0.15) {
game.spawnDamageText('BLOCK', this.body.position, '#99aabb');
playSound('sword_hit1', 0.6);
return;
}
super.takeDamage(damage, game, withKnockback, sourcePos);
this.hitFlash = HIT_FLASH_TIME;
}
protected onDeath(_game: Game) {
this.cleanupEffects();
this.beginFadeDeath();
}
override dispose() {
super.dispose();
this.cleanupEffects();
}
private cleanupEffects() {
this.starsGroup.visible = false;
this.dashLine.visible = false;
this.telegraphArc.visible = false;
this.swingArc.visible = false;
this.shockRing.visible = false;
this.furMat.color.set(0x6b4a2f);
this.darkMat.color.set(0x4a3220);
for (const rock of this.rocks) rock.dispose();
this.rocks = [];
if (this.game) {
for (const c of this.cracks) {
this.game.scene.remove(c.group);
this.disposeGroup(c.group);
}
}
this.cracks = [];
}
private disposeGroup(group: THREE.Group) {
group.traverse((obj) => {
if (obj instanceof THREE.Mesh) {
obj.geometry.dispose();
(obj.material as THREE.Material).dispose();
}
});
}
private lerpAngle(a: number, b: number, t: number): number {
return a + this.angleDiff(a, b) * t;
}
private angleDiff(a: number, b: number): number {
let diff = b - a;
while (diff > Math.PI) diff -= Math.PI * 2;
while (diff < -Math.PI) diff += Math.PI * 2;
return diff;
}
}
+28
View File
@@ -34,6 +34,7 @@ export class Player {
slowFactor = 0.6; slowFactor = 0.6;
pullTime = 0; pullTime = 0;
pullDir = new THREE.Vector3(); pullDir = new THREE.Vector3();
stunTime = 0;
stats: PlayerStats = { stats: PlayerStats = {
maxHealth: 100, maxHealth: 100,
regen: 0, regen: 0,
@@ -52,6 +53,7 @@ export class Player {
private bodyMaterials: THREE.MeshToonMaterial[] = []; private bodyMaterials: THREE.MeshToonMaterial[] = [];
private hurtCooldown = 0; private hurtCooldown = 0;
private flashTime = 0; private flashTime = 0;
private stunStars: THREE.Group;
private static readonly BASE_COLOR = 0x44aa44; private static readonly BASE_COLOR = 0x44aa44;
private static readonly HURT_COLOR = 0xff5555; private static readonly HURT_COLOR = 0xff5555;
private static readonly SLOW_COLOR = 0x9944cc; private static readonly SLOW_COLOR = 0x9944cc;
@@ -60,6 +62,19 @@ export class Player {
constructor() { constructor() {
this.body = new THREE.Group(); this.body = new THREE.Group();
// Stun-Sterne: kreisen um den Kopf, solange der Spieler gestunnt ist
this.stunStars = new THREE.Group();
const starGeom = new THREE.OctahedronGeometry(0.13);
const starMat = new THREE.MeshBasicMaterial({ color: 0xffdd33 });
for (let i = 0; i < 3; i++) {
const a = (i / 3) * Math.PI * 2;
const star = new THREE.Mesh(starGeom, starMat);
star.position.set(Math.cos(a) * 0.55, 1.35, Math.sin(a) * 0.55);
this.stunStars.add(star);
}
this.stunStars.visible = false;
this.body.add(this.stunStars);
// Helmet // Helmet
const helmetGeom = new THREE.SphereGeometry(0.45, 8, 8, 0, Math.PI * 2, 0, Math.PI / 2); const helmetGeom = new THREE.SphereGeometry(0.45, 8, 8, 0, Math.PI * 2, 0, Math.PI / 2);
const helmetMat = new THREE.MeshToonMaterial({ color: 0x888888 }); const helmetMat = new THREE.MeshToonMaterial({ color: 0x888888 });
@@ -223,6 +238,18 @@ export class Player {
if (this.mixer) this.mixer.update(dt); if (this.mixer) this.mixer.update(dt);
if (this.hurtCooldown > 0) this.hurtCooldown -= dt; if (this.hurtCooldown > 0) this.hurtCooldown -= dt;
if (this.slowTimer > 0) this.slowTimer -= dt; if (this.slowTimer > 0) this.slowTimer -= dt;
if (this.stunTime > 0) this.stunTime -= dt;
// Stun-Sterne animieren
this.stunStars.visible = this.stunTime > 0;
if (this.stunStars.visible) {
this.stunStars.rotation.y += dt * 5;
for (const star of this.stunStars.children) {
star.rotation.x += dt * 8;
star.rotation.y += dt * 6;
star.position.y = 1.35 + Math.sin(this.stunTime * 10) * 0.08;
}
}
let color: THREE.Color; let color: THREE.Color;
if (this.flashTime > 0) { if (this.flashTime > 0) {
@@ -332,6 +359,7 @@ export class Player {
this.shieldTimer = 0; this.shieldTimer = 0;
this.slowTimer = 0; this.slowTimer = 0;
this.pullTime = 0; this.pullTime = 0;
this.stunTime = 0;
this.body.position.set(0, 0.5, 0); this.body.position.set(0, 0.5, 0);
this.velocity.set(0, 0, 0); this.velocity.set(0, 0, 0);
this.jumpVelocity = 0; this.jumpVelocity = 0;