Compare commits
3
Commits
96d6917b0b
...
6f207ba1a0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f207ba1a0 | ||
|
|
4751d428a7 | ||
|
|
0b19c3d26b |
@@ -0,0 +1,7 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
## Git-Regeln
|
||||||
|
|
||||||
|
- Git-Commits nur auf explizite Anforderung des Users. Niemals eigenständig committen.
|
||||||
|
- Vor einem Commit: `git status`, `git diff` und `git log --oneline -10` prüfen; nur beabsichtigte Dateien stagen, keine Secrets.
|
||||||
|
- Commit-Message kurz und im Stil des Repos (bisher englisch, imperative).
|
||||||
@@ -5,9 +5,13 @@ Browser-Port des 2012er jMonkeyEngine-Spiels "Wackelpeter" (Blobtest).
|
|||||||
## Übersicht
|
## Übersicht
|
||||||
|
|
||||||
3D Arena-Survival-Spiel: Der Blob "Wackelpeter" kämpft mit Schwert und
|
3D Arena-Survival-Spiel: Der Blob "Wackelpeter" kämpft mit Schwert und
|
||||||
Feuerball-Stab gegen Wellen von Spinnen. Bei 10% Kill-Chance droppt ein
|
Feuerball-Stab gegen Wellen von Monstern. Bei 10% Kill-Chance droppt ein
|
||||||
heilendes Kreuz. Cel-Shading-Look, Wellen werden alle 10 Sekunden stärker.
|
heilendes Kreuz. Cel-Shading-Look, Wellen werden alle 10 Sekunden stärker.
|
||||||
|
|
||||||
|
Monster: Spinnen (ab Welle 1), Geister (ab 3), springende Frösche (ab 4),
|
||||||
|
Schleime (ab 5, teilen sich), Fledermäuse (ab 5), Pilz-Türme (ab 6),
|
||||||
|
Krabben (ab 7), Bienenschwärme (ab 7) und alle 5 Wellen ein Boss-Golem.
|
||||||
|
|
||||||
## Restaurierung
|
## Restaurierung
|
||||||
|
|
||||||
Das Original lag nur kompiliert vor (Blobtest.exe + JME3-Libs). Der Source
|
Das Original lag nur kompiliert vor (Blobtest.exe + JME3-Libs). Der Source
|
||||||
@@ -45,8 +49,11 @@ npm run dev
|
|||||||
|
|
||||||
- Three.js + TypeScript + Vite
|
- Three.js + TypeScript + Vite
|
||||||
- Steuerung: WASD bewegen, Space springen, Maus zielen,
|
- Steuerung: WASD bewegen, Space springen, Maus zielen,
|
||||||
Linksklick Feuerball, Rechtsklick Schwert, M3 Hände tauschen,
|
Linksklick Schwert, Rechtsklick Feuerball (per Skill),
|
||||||
E Teleport, H Hitboxen (Debug)
|
Q Kettenblitz (per Skill), E Teleport (per Skill), H Hitboxen (Debug)
|
||||||
|
- Roguelike-Skillsystem: Kills geben XP, bei Level-Up pausiert das Spiel
|
||||||
|
und bietet 3 Skill-Karten (neue Skills oder Upgrades). Seed (Startmenü)
|
||||||
|
steuert die Angebote, "Nochmal spielen" behält den Seed bei.
|
||||||
|
|
||||||
## Verzeichnisse
|
## Verzeichnisse
|
||||||
|
|
||||||
|
|||||||
+170
-5
@@ -7,7 +7,10 @@
|
|||||||
<style>
|
<style>
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
html, body { width: 100%; height: 100%; overflow: hidden; background: #000; }
|
html, body { width: 100%; height: 100%; overflow: hidden; background: #000; }
|
||||||
canvas { display: block; }
|
canvas {
|
||||||
|
display: block;
|
||||||
|
cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40' viewBox='0 0 40 40'%3E%3Cg fill='none' stroke='%23140d02' stroke-width='4.5' stroke-linecap='round'%3E%3Ccircle cx='20' cy='20' r='13'/%3E%3Cpath d='M20 2v5M20 33v5M2 20h5M33 20h5'/%3E%3C/g%3E%3Cg fill='none' stroke='%23ffc83d' stroke-width='2' stroke-linecap='round'%3E%3Ccircle cx='20' cy='20' r='13'/%3E%3Cpath d='M20 2v5M20 33v5M2 20h5M33 20h5'/%3E%3C/g%3E%3Cg fill='%23ffc83d' stroke='%23140d02' stroke-width='1'%3E%3Cpath d='M20 0l1.6 3.4L18.4 3.4Z'/%3E%3Cpath d='M20 40l1.6-3.4L18.4 36.6Z'/%3E%3Cpath d='M0 20l3.4-1.6L3.4 21.6Z'/%3E%3Cpath d='M40 20l-3.4-1.6L36.6 21.6Z'/%3E%3C/g%3E%3Cg fill='%23ffc83d' stroke='%23140d02' stroke-width='1'%3E%3Crect x='8.2' y='8.2' width='3.6' height='3.6' transform='rotate(45 10 10)'/%3E%3Crect x='28.2' y='8.2' width='3.6' height='3.6' transform='rotate(45 30 10)'/%3E%3Crect x='8.2' y='28.2' width='3.6' height='3.6' transform='rotate(45 10 30)'/%3E%3Crect x='28.2' y='28.2' width='3.6' height='3.6' transform='rotate(45 30 30)'/%3E%3C/g%3E%3Ccircle cx='20' cy='20' r='2.6' fill='%23ffe9a8' stroke='%23140d02' stroke-width='1'/%3E%3C/svg%3E") 20 20, crosshair;
|
||||||
|
}
|
||||||
#ui-overlay {
|
#ui-overlay {
|
||||||
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
|
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
|
||||||
pointer-events: none; font-family: monospace; z-index: 10;
|
pointer-events: none; font-family: monospace; z-index: 10;
|
||||||
@@ -50,6 +53,7 @@
|
|||||||
#fireball-cooldown { bottom: 72px; }
|
#fireball-cooldown { bottom: 72px; }
|
||||||
#sword-cooldown { bottom: 48px; }
|
#sword-cooldown { bottom: 48px; }
|
||||||
#teleport-cooldown { bottom: 24px; }
|
#teleport-cooldown { bottom: 24px; }
|
||||||
|
#lightning-cooldown { bottom: 96px; }
|
||||||
#wave-bar-container {
|
#wave-bar-container {
|
||||||
position: absolute; top: 16px; right: 16px; width: 250px; height: 16px;
|
position: absolute; top: 16px; right: 16px; width: 250px; height: 16px;
|
||||||
}
|
}
|
||||||
@@ -70,22 +74,139 @@
|
|||||||
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; }
|
||||||
|
#btn-confirm { font-size: 18px; padding: 10px 36px; }
|
||||||
|
#btn-confirm:disabled { opacity: 0.35; cursor: default; }
|
||||||
#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;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
#seed-input {
|
||||||
|
background: rgba(0,0,0,0.7); border: 2px solid #555; color: #ffcc00;
|
||||||
|
font-family: monospace; font-size: 16px; padding: 6px 10px; width: 180px;
|
||||||
|
text-align: center; outline: none;
|
||||||
|
}
|
||||||
|
#seed-input:focus { border-color: #ffcc00; }
|
||||||
|
#level-label {
|
||||||
|
position: absolute; top: 96px; left: 16px; color: #88ff88; font-size: 16px;
|
||||||
|
font-family: monospace; text-shadow: 2px 2px 4px #000;
|
||||||
|
}
|
||||||
|
#seed-label {
|
||||||
|
position: absolute; top: 96px; right: 16px; color: rgba(255,255,255,0.6);
|
||||||
|
font-size: 12px; font-family: monospace; text-shadow: 1px 1px 2px #000;
|
||||||
|
}
|
||||||
|
#xp-bar-container {
|
||||||
|
position: absolute; top: 120px; left: 16px; width: 250px; height: 12px;
|
||||||
|
}
|
||||||
|
#xp-bar-bg {
|
||||||
|
width: 100%; height: 100%; background: rgba(0,0,0,0.6); border: 1px solid #555;
|
||||||
|
}
|
||||||
|
#xp-bar-fill {
|
||||||
|
height: 100%; background: #44cc44; transition: width 0.1s linear;
|
||||||
|
}
|
||||||
|
#xp-bar-label {
|
||||||
|
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
|
||||||
|
text-align: center; line-height: 12px; color: #fff; font-size: 10px;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
#shield-bar-bg {
|
||||||
|
position: absolute; top: 44px; left: 16px; width: 250px; height: 10px;
|
||||||
|
background: rgba(0,0,0,0.6); border: 1px solid #555; display: none;
|
||||||
|
}
|
||||||
|
#shield-bar {
|
||||||
|
position: absolute; top: 44px; left: 16px; width: 250px; height: 10px;
|
||||||
|
background: #3399ff; transition: width 0.1s linear; display: none;
|
||||||
|
}
|
||||||
|
#shield-text {
|
||||||
|
position: absolute; top: 44px; left: 16px; width: 250px; height: 10px;
|
||||||
|
text-align: center; line-height: 10px; color: #fff; font-size: 9px;
|
||||||
|
font-family: monospace; display: none;
|
||||||
|
}
|
||||||
|
#level-up {
|
||||||
|
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
|
||||||
|
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||||
|
gap: 24px; background: rgba(0,0,0,0.7); opacity: 0; pointer-events: none;
|
||||||
|
transition: opacity 0.3s ease; font-family: monospace;
|
||||||
|
}
|
||||||
|
#level-up h2 {
|
||||||
|
color: #ffcc00; font-size: 44px; text-shadow: 3px 3px 6px #000;
|
||||||
|
}
|
||||||
|
#offer-row {
|
||||||
|
display: flex; gap: 20px;
|
||||||
|
}
|
||||||
|
.offer-card {
|
||||||
|
width: 230px; min-height: 140px; background: rgba(20,20,30,0.9);
|
||||||
|
border: 2px solid #888; padding: 14px; cursor: pointer;
|
||||||
|
display: flex; flex-direction: column; gap: 10px;
|
||||||
|
transition: transform 0.1s ease, border-color 0.1s ease;
|
||||||
|
}
|
||||||
|
.offer-card:hover { border-color: #ffcc00; background: rgba(40,40,50,0.95); }
|
||||||
|
.offer-card.selected {
|
||||||
|
border-color: #ffcc00; background: rgba(60,50,20,0.95);
|
||||||
|
transform: scale(1.04); box-shadow: 0 0 18px rgba(255,204,0,0.4);
|
||||||
|
}
|
||||||
|
.offer-title {
|
||||||
|
color: #ffcc00; font-size: 18px; font-weight: bold;
|
||||||
|
}
|
||||||
|
.offer-desc {
|
||||||
|
color: rgba(255,255,255,0.85); font-size: 13px; line-height: 1.5;
|
||||||
|
}
|
||||||
|
.offer-hint {
|
||||||
|
color: rgba(255,255,255,0.4); font-size: 12px; margin-top: auto;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="ui-overlay">
|
<div id="ui-overlay">
|
||||||
<div id="health-bar-bg"></div>
|
<div id="health-bar-bg"></div>
|
||||||
<div id="health-bar"></div>
|
<div id="health-bar"></div>
|
||||||
<div id="health-text">100%</div>
|
<div id="health-text">100</div>
|
||||||
|
<div id="shield-bar-bg"></div>
|
||||||
|
<div id="shield-bar"></div>
|
||||||
|
<div id="shield-text"></div>
|
||||||
<div id="wave-label">Wave 0</div>
|
<div id="wave-label">Wave 0</div>
|
||||||
<div id="kills-label">Kills: 0</div>
|
<div id="kills-label">Kills: 0</div>
|
||||||
|
<div id="level-label">Level 0</div>
|
||||||
|
<div id="xp-bar-container">
|
||||||
|
<div id="xp-bar-bg"></div>
|
||||||
|
<div id="xp-bar-fill"></div>
|
||||||
|
<div id="xp-bar-label">XP: 0/20</div>
|
||||||
|
</div>
|
||||||
|
<div id="seed-label"></div>
|
||||||
|
|
||||||
<div id="fireball-cooldown" class="cooldown-container">
|
<div id="fireball-cooldown" class="cooldown-container" style="display: none">
|
||||||
<div class="cooldown-bg"></div>
|
<div class="cooldown-bg"></div>
|
||||||
<div class="cooldown-fill"></div>
|
<div class="cooldown-fill"></div>
|
||||||
<div class="cooldown-label">Fireball</div>
|
<div class="cooldown-label">Fireball</div>
|
||||||
@@ -95,11 +216,16 @@
|
|||||||
<div class="cooldown-fill"></div>
|
<div class="cooldown-fill"></div>
|
||||||
<div class="cooldown-label">Sword</div>
|
<div class="cooldown-label">Sword</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="teleport-cooldown" class="cooldown-container">
|
<div id="teleport-cooldown" class="cooldown-container" style="display: none">
|
||||||
<div class="cooldown-bg"></div>
|
<div class="cooldown-bg"></div>
|
||||||
<div class="cooldown-fill"></div>
|
<div class="cooldown-fill"></div>
|
||||||
<div class="cooldown-label">Teleport</div>
|
<div class="cooldown-label">Teleport</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="lightning-cooldown" class="cooldown-container" style="display: none">
|
||||||
|
<div class="cooldown-bg"></div>
|
||||||
|
<div class="cooldown-fill"></div>
|
||||||
|
<div class="cooldown-label">Chain Lightning</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="wave-bar-container">
|
<div id="wave-bar-container">
|
||||||
<div id="wave-bar-bg"></div>
|
<div id="wave-bar-bg"></div>
|
||||||
@@ -108,8 +234,47 @@
|
|||||||
</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="level-up">
|
||||||
|
<h2>Level Up!</h2>
|
||||||
|
<div id="offer-row">
|
||||||
|
<div id="offer-0" class="offer-card">
|
||||||
|
<div id="offer-title-0" class="offer-title"></div>
|
||||||
|
<div id="offer-desc-0" class="offer-desc"></div>
|
||||||
|
<div class="offer-hint">[1] auswählen</div>
|
||||||
|
</div>
|
||||||
|
<div id="offer-1" class="offer-card">
|
||||||
|
<div id="offer-title-1" class="offer-title"></div>
|
||||||
|
<div id="offer-desc-1" class="offer-desc"></div>
|
||||||
|
<div class="offer-hint">[2] auswählen</div>
|
||||||
|
</div>
|
||||||
|
<div id="offer-2" class="offer-card">
|
||||||
|
<div id="offer-title-2" class="offer-title"></div>
|
||||||
|
<div id="offer-desc-2" class="offer-desc"></div>
|
||||||
|
<div class="offer-hint">[3] auswählen</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button id="btn-confirm" class="menu-button" disabled>Bestätigen (Enter)</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: Schwert · Rechtsklick: Feuerball (per Skill) · Q: Kettenblitz (per Skill) · E: Teleport (per Skill)
|
||||||
|
</div>
|
||||||
|
<input id="seed-input" type="text" placeholder="Seed (leer = zufällig)" />
|
||||||
|
<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: Sword · Right Click: Fireball · Q: Lightning · E: Teleport
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
|||||||
+119
@@ -0,0 +1,119 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import type { Game } from './Game';
|
||||||
|
import { Enemy } from './Enemy';
|
||||||
|
|
||||||
|
export class Bat extends Enemy {
|
||||||
|
private model: THREE.Group;
|
||||||
|
private wingL: THREE.Mesh;
|
||||||
|
private wingR: THREE.Mesh;
|
||||||
|
private flapTime = Math.random() * Math.PI * 2;
|
||||||
|
private flyTime = Math.random() * Math.PI * 2;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.health = 25;
|
||||||
|
this.speed = 8;
|
||||||
|
this.xp = 10;
|
||||||
|
|
||||||
|
this.model = new THREE.Group();
|
||||||
|
this.fadeOutModel = this.model;
|
||||||
|
this.body.add(this.model);
|
||||||
|
|
||||||
|
const furMat = new THREE.MeshToonMaterial({ color: 0x5a4568 });
|
||||||
|
|
||||||
|
// Body
|
||||||
|
const bodyMesh = new THREE.Mesh(new THREE.SphereGeometry(0.3, 14, 12), furMat);
|
||||||
|
bodyMesh.scale.set(1, 0.9, 1.3);
|
||||||
|
bodyMesh.castShadow = true;
|
||||||
|
this.registerFadeMesh(bodyMesh);
|
||||||
|
this.model.add(bodyMesh);
|
||||||
|
|
||||||
|
// Head with ears (face toward the player: -Z)
|
||||||
|
const head = new THREE.Mesh(new THREE.SphereGeometry(0.18, 12, 10), furMat);
|
||||||
|
head.position.set(0, 0.15, -0.3);
|
||||||
|
this.registerFadeMesh(head);
|
||||||
|
this.model.add(head);
|
||||||
|
|
||||||
|
for (const side of [-1, 1]) {
|
||||||
|
const ear = new THREE.Mesh(new THREE.ConeGeometry(0.07, 0.18, 6), furMat);
|
||||||
|
ear.position.set(side * 0.12, 0.32, -0.25);
|
||||||
|
this.registerFadeMesh(ear);
|
||||||
|
this.model.add(ear);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eyes
|
||||||
|
const eyeMat = new THREE.MeshToonMaterial({ color: 0xff2222 });
|
||||||
|
for (const side of [-1, 1]) {
|
||||||
|
const eye = new THREE.Mesh(new THREE.SphereGeometry(0.035, 8, 8), eyeMat);
|
||||||
|
eye.position.set(side * 0.07, 0.16, -0.42);
|
||||||
|
this.registerFadeMesh(eye);
|
||||||
|
this.model.add(eye);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wings (hinged at the body, flap via rotation.z)
|
||||||
|
const wingGeom = new THREE.PlaneGeometry(0.75, 0.5, 1, 4);
|
||||||
|
wingGeom.translate(0.3, 0, 0);
|
||||||
|
const wingMat = new THREE.MeshToonMaterial({
|
||||||
|
color: 0x443355,
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.9,
|
||||||
|
});
|
||||||
|
this.wingL = new THREE.Mesh(wingGeom, wingMat);
|
||||||
|
this.wingR = new THREE.Mesh(wingGeom.clone(), wingMat);
|
||||||
|
this.wingL.position.set(-0.3, 0.05, 0);
|
||||||
|
this.wingR.position.set(0.3, 0.05, 0);
|
||||||
|
this.registerFadeMesh(this.wingL);
|
||||||
|
this.registerFadeMesh(this.wingR);
|
||||||
|
this.model.add(this.wingL, this.wingR);
|
||||||
|
}
|
||||||
|
|
||||||
|
playAnim() {
|
||||||
|
// Procedural visuals only
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAnimation(dt: number) {
|
||||||
|
this.updateFadeDeath(dt, { duration: 0.7, sink: 3 });
|
||||||
|
}
|
||||||
|
|
||||||
|
update(dt: number, game: Game) {
|
||||||
|
this.flapTime += dt * 14;
|
||||||
|
this.flyTime += dt * 3;
|
||||||
|
|
||||||
|
const flap = Math.sin(this.flapTime) * 0.9;
|
||||||
|
this.wingL.rotation.z = -1.1 - flap;
|
||||||
|
this.wingR.rotation.z = 1.1 + flap;
|
||||||
|
|
||||||
|
// Bob up and down while swooping at the player
|
||||||
|
this.body.position.y = 1.0 + Math.sin(this.flyTime) * 0.5;
|
||||||
|
|
||||||
|
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
|
||||||
|
);
|
||||||
|
// Slight zigzag
|
||||||
|
const perp = new THREE.Vector3(-dir.z, 0, dir.x)
|
||||||
|
.multiplyScalar(Math.sin(this.flyTime * 2.2) * 0.6);
|
||||||
|
|
||||||
|
let moveDir = dir.clone().multiplyScalar(this.speed).add(perp);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onDeath(_game: Game) {
|
||||||
|
this.beginFadeDeath();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import type { Game } from './Game';
|
||||||
|
import { Enemy } from './Enemy';
|
||||||
|
|
||||||
|
const BEE_COUNT = 4;
|
||||||
|
|
||||||
|
interface SwarmBee {
|
||||||
|
mesh: THREE.Group;
|
||||||
|
wingL: THREE.Mesh;
|
||||||
|
wingR: THREE.Mesh;
|
||||||
|
angle: number;
|
||||||
|
radius: number;
|
||||||
|
orbitSpeed: number;
|
||||||
|
scatter: THREE.Vector3;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BeeSwarm extends Enemy {
|
||||||
|
private model: THREE.Group;
|
||||||
|
private bees: SwarmBee[] = [];
|
||||||
|
private time = Math.random() * Math.PI * 2;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.health = 50;
|
||||||
|
this.speed = 6.5;
|
||||||
|
this.xp = 15;
|
||||||
|
|
||||||
|
this.model = new THREE.Group();
|
||||||
|
this.body.add(this.model);
|
||||||
|
|
||||||
|
const bodyGeom = new THREE.CapsuleGeometry(0.11, 0.16, 4, 10);
|
||||||
|
const bodyMat = new THREE.MeshToonMaterial({ color: 0xffcc33 });
|
||||||
|
const stripeMat = new THREE.MeshToonMaterial({ color: 0x222222 });
|
||||||
|
const wingMat = new THREE.MeshToonMaterial({
|
||||||
|
color: 0xffffff,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.7,
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = 0; i < BEE_COUNT; i++) {
|
||||||
|
const bee = new THREE.Group();
|
||||||
|
|
||||||
|
const body = new THREE.Mesh(bodyGeom, bodyMat);
|
||||||
|
body.rotation.x = Math.PI / 2;
|
||||||
|
bee.add(body);
|
||||||
|
|
||||||
|
for (const stripeZ of [-0.1, 0.1]) {
|
||||||
|
const stripe = new THREE.Mesh(new THREE.CylinderGeometry(0.115, 0.115, 0.05, 10), stripeMat);
|
||||||
|
stripe.rotation.x = Math.PI / 2;
|
||||||
|
stripe.position.z = stripeZ;
|
||||||
|
bee.add(stripe);
|
||||||
|
}
|
||||||
|
|
||||||
|
const wingL = new THREE.Mesh(new THREE.PlaneGeometry(0.14, 0.24), wingMat);
|
||||||
|
wingL.position.set(0.12, 0.08, -0.02);
|
||||||
|
wingL.rotation.y = Math.PI / 2;
|
||||||
|
bee.add(wingL);
|
||||||
|
const wingR = wingL.clone();
|
||||||
|
wingR.position.x = -0.12;
|
||||||
|
bee.add(wingR);
|
||||||
|
|
||||||
|
bee.scale.setScalar(0.9);
|
||||||
|
this.model.add(bee);
|
||||||
|
this.bees.push({
|
||||||
|
mesh: bee,
|
||||||
|
wingL,
|
||||||
|
wingR,
|
||||||
|
angle: (i / BEE_COUNT) * Math.PI * 2,
|
||||||
|
radius: 0.4 + (i % 2) * 0.15,
|
||||||
|
orbitSpeed: 2 + (i % 3) * 0.4,
|
||||||
|
scatter: new THREE.Vector3(
|
||||||
|
Math.cos((i / BEE_COUNT) * Math.PI * 2),
|
||||||
|
0.5 + Math.random(),
|
||||||
|
Math.sin((i / BEE_COUNT) * Math.PI * 2)
|
||||||
|
).multiplyScalar(3),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.fadeOutModel = this.model;
|
||||||
|
}
|
||||||
|
|
||||||
|
playAnim() {
|
||||||
|
// Procedural visuals only
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAnimation(dt: number) {
|
||||||
|
if (this.dead) {
|
||||||
|
// Scatter the bees outward while fading
|
||||||
|
this.time += dt;
|
||||||
|
for (const bee of this.bees) {
|
||||||
|
bee.mesh.position.x += bee.scatter.x * dt;
|
||||||
|
bee.mesh.position.y += bee.scatter.y * dt;
|
||||||
|
bee.mesh.position.z += bee.scatter.z * dt;
|
||||||
|
}
|
||||||
|
this.updateFadeDeath(dt, { duration: 1.0, shrink: 0.4 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update(dt: number, game: Game) {
|
||||||
|
this.time += dt;
|
||||||
|
this.body.position.y = 1.2 + Math.sin(this.time * 2) * 0.2;
|
||||||
|
|
||||||
|
// Orbit the individual bees around the cluster center
|
||||||
|
for (const bee of this.bees) {
|
||||||
|
bee.angle += bee.orbitSpeed * dt;
|
||||||
|
bee.mesh.position.set(
|
||||||
|
Math.cos(bee.angle) * bee.radius,
|
||||||
|
Math.sin(bee.angle * 2.3) * 0.25,
|
||||||
|
Math.sin(bee.angle) * bee.radius
|
||||||
|
);
|
||||||
|
bee.mesh.rotation.y = -bee.angle;
|
||||||
|
const flap = Math.sin(this.time * 40 + bee.angle) * 0.9;
|
||||||
|
bee.wingL.rotation.x = flap;
|
||||||
|
bee.wingR.rotation.x = -flap;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
);
|
||||||
|
const perp = new THREE.Vector3(-dir.z, 0, dir.x)
|
||||||
|
.multiplyScalar(Math.sin(this.time * 4) * 0.8);
|
||||||
|
|
||||||
|
let moveDir = dir.clone().multiplyScalar(this.speed).add(perp);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onDeath(_game: Game) {
|
||||||
|
this.beginFadeDeath();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import type { Game } from './Game';
|
||||||
|
import { Brute } from './Brute';
|
||||||
|
|
||||||
|
export class Boss extends Brute {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.health = 800;
|
||||||
|
this.speed = 3;
|
||||||
|
this.xp = 80;
|
||||||
|
this.contactDps = 50;
|
||||||
|
this.contactRadius = 3.4;
|
||||||
|
this.guaranteedDrop = true;
|
||||||
|
|
||||||
|
this.model.scale.setScalar(2.1);
|
||||||
|
|
||||||
|
// Crown
|
||||||
|
const crownMat = new THREE.MeshToonMaterial({ color: 0xffcc33 });
|
||||||
|
const crown = new THREE.Mesh(
|
||||||
|
new THREE.ConeGeometry(0.3, 0.4, 8, 1, true),
|
||||||
|
crownMat
|
||||||
|
);
|
||||||
|
crown.position.y = 2.25;
|
||||||
|
crown.castShadow = true;
|
||||||
|
this.registerFadeMesh(crown);
|
||||||
|
this.model.add(crown);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onDeath(_game: Game) {
|
||||||
|
this.beginFadeDeath();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import type { Game } from './Game';
|
||||||
|
import { Enemy } from './Enemy';
|
||||||
|
|
||||||
|
export class Brute extends Enemy {
|
||||||
|
protected model: THREE.Group;
|
||||||
|
private stepTime = 0;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.health = 300;
|
||||||
|
this.speed = 3.5;
|
||||||
|
this.xp = 40;
|
||||||
|
this.contactDps = 40;
|
||||||
|
this.contactRadius = 2.4;
|
||||||
|
|
||||||
|
this.model = new THREE.Group();
|
||||||
|
this.fadeOutModel = this.model;
|
||||||
|
this.body.add(this.model);
|
||||||
|
|
||||||
|
const rockMat = new THREE.MeshToonMaterial({ color: 0x6a6a78 });
|
||||||
|
const darkMat = new THREE.MeshToonMaterial({ color: 0x3c3c46 });
|
||||||
|
|
||||||
|
// Torso
|
||||||
|
const torso = new THREE.Mesh(new THREE.BoxGeometry(1.3, 1.2, 0.95), rockMat);
|
||||||
|
torso.position.y = 0.95;
|
||||||
|
torso.castShadow = true;
|
||||||
|
this.registerFadeMesh(torso);
|
||||||
|
this.model.add(torso);
|
||||||
|
|
||||||
|
// Shoulders
|
||||||
|
for (const side of [-1, 1]) {
|
||||||
|
const shoulder = new THREE.Mesh(new THREE.SphereGeometry(0.38, 12, 10), rockMat);
|
||||||
|
shoulder.position.set(side * 0.8, 1.35, 0);
|
||||||
|
shoulder.castShadow = true;
|
||||||
|
this.registerFadeMesh(shoulder);
|
||||||
|
this.model.add(shoulder);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arms
|
||||||
|
for (const side of [-1, 1]) {
|
||||||
|
const arm = new THREE.Mesh(new THREE.CylinderGeometry(0.22, 0.28, 0.9, 8), rockMat);
|
||||||
|
arm.position.set(side * 0.85, 0.55, 0);
|
||||||
|
arm.castShadow = true;
|
||||||
|
this.registerFadeMesh(arm);
|
||||||
|
this.model.add(arm);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Head
|
||||||
|
const head = new THREE.Mesh(new THREE.BoxGeometry(0.6, 0.55, 0.6), darkMat);
|
||||||
|
head.position.y = 1.85;
|
||||||
|
head.castShadow = true;
|
||||||
|
this.registerFadeMesh(head);
|
||||||
|
this.model.add(head);
|
||||||
|
|
||||||
|
// Glowing eyes (face toward the player: -Z)
|
||||||
|
const eyeMat = new THREE.MeshBasicMaterial({ color: 0xff6622 });
|
||||||
|
for (const side of [-1, 1]) {
|
||||||
|
const eye = new THREE.Mesh(new THREE.BoxGeometry(0.14, 0.1, 0.05), eyeMat);
|
||||||
|
eye.position.set(side * 0.16, 1.9, -0.32);
|
||||||
|
this.registerFadeMesh(eye);
|
||||||
|
this.model.add(eye);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
playAnim() {
|
||||||
|
// Procedural visuals only
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAnimation(dt: number) {
|
||||||
|
this.updateFadeDeath(dt, { duration: 1.2, sink: 0.5 });
|
||||||
|
}
|
||||||
|
|
||||||
|
update(dt: number, game: Game) {
|
||||||
|
this.stepTime += dt;
|
||||||
|
|
||||||
|
// Heavy footsteps
|
||||||
|
this.body.position.y = 0.15 + Math.abs(Math.sin(this.stepTime * 3)) * 0.07;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onDeath(_game: Game) {
|
||||||
|
this.beginFadeDeath();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import { Weapon } from './Weapon';
|
||||||
|
import { Player } from './Player';
|
||||||
|
import { lightningDamage, lightningJumps, lightningCooldown } from './Skills';
|
||||||
|
|
||||||
|
const CHAIN_FIRST_RANGE = 14;
|
||||||
|
const CHAIN_JUMP_RANGE = 8;
|
||||||
|
const FLASH_TIME = 0.35;
|
||||||
|
|
||||||
|
export class ChainLightning extends Weapon {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.COOLDOWN1_TIME = lightningCooldown(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
skill1(_groundPoint: THREE.Vector3, player: Player): void {
|
||||||
|
const skills = player.game.skillSystem;
|
||||||
|
const level = skills.getLevel('chainlightning');
|
||||||
|
if (level <= 0) return;
|
||||||
|
if (this.cooldown1 > 0) return;
|
||||||
|
|
||||||
|
this.COOLDOWN1_TIME = lightningCooldown(level);
|
||||||
|
this.cooldown1 = this.COOLDOWN1_TIME;
|
||||||
|
|
||||||
|
const game = player.game;
|
||||||
|
const damage = lightningDamage(level);
|
||||||
|
const maxJumps = lightningJumps(level);
|
||||||
|
|
||||||
|
// Chain through enemies, always the closest un-hit one
|
||||||
|
const start = player.body.position.clone();
|
||||||
|
start.y += 0.8;
|
||||||
|
const chainPoints: THREE.Vector3[] = [start];
|
||||||
|
const hit: Set<import('./Enemy').Enemy> = new Set();
|
||||||
|
let from = player.body.position.clone();
|
||||||
|
|
||||||
|
for (let i = 0; i < maxJumps; i++) {
|
||||||
|
let best: import('./Enemy').Enemy | null = null;
|
||||||
|
let bestDist = i === 0 ? CHAIN_FIRST_RANGE : CHAIN_JUMP_RANGE;
|
||||||
|
for (const enemy of game.enemies) {
|
||||||
|
if (enemy.dead || hit.has(enemy)) continue;
|
||||||
|
const dist = enemy.body.position.distanceTo(from);
|
||||||
|
if (dist < bestDist) {
|
||||||
|
bestDist = dist;
|
||||||
|
best = enemy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!best) break;
|
||||||
|
|
||||||
|
hit.add(best);
|
||||||
|
const fromPoint = from.clone();
|
||||||
|
best.takeDamage(damage, game, true, fromPoint);
|
||||||
|
const point = best.body.position.clone();
|
||||||
|
point.y += 0.8;
|
||||||
|
chainPoints.push(point);
|
||||||
|
from = best.body.position;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chainPoints.length < 2) return;
|
||||||
|
|
||||||
|
this.spawnVisual(game.scene, chainPoints, hit);
|
||||||
|
}
|
||||||
|
|
||||||
|
skill2(): void {
|
||||||
|
// Not implemented
|
||||||
|
}
|
||||||
|
|
||||||
|
private spawnVisual(
|
||||||
|
scene: THREE.Scene,
|
||||||
|
points: THREE.Vector3[],
|
||||||
|
hit: Set<import('./Enemy').Enemy>
|
||||||
|
) {
|
||||||
|
const group = new THREE.Group();
|
||||||
|
scene.add(group);
|
||||||
|
|
||||||
|
// Segments between chain points
|
||||||
|
for (let i = 0; i < points.length - 1; i++) {
|
||||||
|
const lineGeom = new THREE.BufferGeometry().setFromPoints([
|
||||||
|
points[i],
|
||||||
|
points[i + 1],
|
||||||
|
]);
|
||||||
|
const lineMat = new THREE.LineBasicMaterial({
|
||||||
|
color: 0x88ccff,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.9,
|
||||||
|
blending: THREE.AdditiveBlending,
|
||||||
|
depthWrite: false,
|
||||||
|
});
|
||||||
|
const line = new THREE.Line(lineGeom, lineMat);
|
||||||
|
group.add(line);
|
||||||
|
|
||||||
|
// Jagged inner bolt
|
||||||
|
const mid = new THREE.Vector3()
|
||||||
|
.addVectors(points[i], points[i + 1])
|
||||||
|
.multiplyScalar(0.5);
|
||||||
|
mid.x += (Math.random() - 0.5) * 1.2;
|
||||||
|
mid.y += (Math.random() - 0.5) * 1.2;
|
||||||
|
mid.z += (Math.random() - 0.5) * 1.2;
|
||||||
|
const boltGeom = new THREE.BufferGeometry().setFromPoints([
|
||||||
|
points[i], mid, points[i + 1],
|
||||||
|
]);
|
||||||
|
const boltMat = new THREE.LineBasicMaterial({
|
||||||
|
color: 0xffffff,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.9,
|
||||||
|
blending: THREE.AdditiveBlending,
|
||||||
|
depthWrite: false,
|
||||||
|
});
|
||||||
|
const bolt = new THREE.Line(boltGeom, boltMat);
|
||||||
|
group.add(bolt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flash spheres on hit enemies
|
||||||
|
const flashes: THREE.Mesh[] = [];
|
||||||
|
for (const enemy of hit) {
|
||||||
|
const flashGeom = new THREE.SphereGeometry(0.7, 12, 12);
|
||||||
|
const flashMat = new THREE.MeshBasicMaterial({
|
||||||
|
color: 0x88ccff,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.7,
|
||||||
|
blending: THREE.AdditiveBlending,
|
||||||
|
depthWrite: false,
|
||||||
|
});
|
||||||
|
const flash = new THREE.Mesh(flashGeom, flashMat);
|
||||||
|
flash.position.copy(enemy.body.position);
|
||||||
|
flash.position.y += 0.8;
|
||||||
|
group.add(flash);
|
||||||
|
flashes.push(flash);
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = performance.now();
|
||||||
|
const update = () => {
|
||||||
|
const elapsed = (performance.now() - startTime) / 1000;
|
||||||
|
if (elapsed > FLASH_TIME) {
|
||||||
|
group.removeFromParent();
|
||||||
|
for (const child of [...group.children]) {
|
||||||
|
if (child instanceof THREE.Line || child instanceof THREE.Mesh) {
|
||||||
|
child.geometry.dispose();
|
||||||
|
(child.material as THREE.Material).dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fade = 1 - elapsed / FLASH_TIME;
|
||||||
|
for (const child of group.children) {
|
||||||
|
if (child instanceof THREE.Line || child instanceof THREE.Mesh) {
|
||||||
|
(child.material as THREE.Material & { opacity: number }).opacity = fade;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const flash of flashes) {
|
||||||
|
const s = 0.7 + elapsed * 3;
|
||||||
|
flash.scale.set(s, s, s);
|
||||||
|
}
|
||||||
|
requestAnimationFrame(update);
|
||||||
|
};
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
}
|
||||||
+165
@@ -0,0 +1,165 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import type { Game } from './Game';
|
||||||
|
import { Enemy } from './Enemy';
|
||||||
|
|
||||||
|
export class Crab extends Enemy {
|
||||||
|
private model: THREE.Group;
|
||||||
|
private clawL: THREE.Mesh;
|
||||||
|
private clawR: THREE.Mesh;
|
||||||
|
private time = Math.random() * Math.PI * 2;
|
||||||
|
private side = Math.random() < 0.5 ? 1 : -1;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.health = 90;
|
||||||
|
this.speed = 4.5;
|
||||||
|
this.xp = 20;
|
||||||
|
this.contactRadius = 2.2;
|
||||||
|
|
||||||
|
this.model = new THREE.Group();
|
||||||
|
this.fadeOutModel = this.model;
|
||||||
|
this.body.add(this.model);
|
||||||
|
|
||||||
|
const shellMat = new THREE.MeshToonMaterial({ color: 0xdd5533 });
|
||||||
|
const darkMat = new THREE.MeshToonMaterial({ color: 0x993322 });
|
||||||
|
|
||||||
|
// Shell body
|
||||||
|
const shell = new THREE.Mesh(new THREE.SphereGeometry(0.55, 18, 14), shellMat);
|
||||||
|
shell.scale.set(1, 0.55, 0.85);
|
||||||
|
shell.position.y = 0.35;
|
||||||
|
shell.castShadow = true;
|
||||||
|
this.registerFadeMesh(shell);
|
||||||
|
this.model.add(shell);
|
||||||
|
|
||||||
|
// Shell spots
|
||||||
|
for (const [dx, dz] of [[0, -0.15], [0.25, 0.1], [-0.25, 0.1]] as const) {
|
||||||
|
const spot = new THREE.Mesh(new THREE.SphereGeometry(0.08, 8, 8), darkMat);
|
||||||
|
spot.scale.set(1, 0.4, 1);
|
||||||
|
spot.position.set(dx, 0.62, dz);
|
||||||
|
this.registerFadeMesh(spot);
|
||||||
|
this.model.add(spot);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eye stalks (on the armored front side, -Z)
|
||||||
|
const stalkMat = new THREE.MeshToonMaterial({ color: 0xdd5533 });
|
||||||
|
const eyeMat = new THREE.MeshToonMaterial({ color: 0x111122 });
|
||||||
|
for (const side of [-1, 1]) {
|
||||||
|
const stalk = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.035, 0.22, 6), stalkMat);
|
||||||
|
stalk.position.set(side * 0.22, 0.55, -0.42);
|
||||||
|
this.registerFadeMesh(stalk);
|
||||||
|
this.model.add(stalk);
|
||||||
|
const eye = new THREE.Mesh(new THREE.SphereGeometry(0.09, 10, 8), eyeMat);
|
||||||
|
eye.position.set(side * 0.22, 0.68, -0.42);
|
||||||
|
this.registerFadeMesh(eye);
|
||||||
|
this.model.add(eye);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legs
|
||||||
|
const legGeom = new THREE.CapsuleGeometry(0.05, 0.25, 4, 8);
|
||||||
|
for (const side of [-1, 1]) {
|
||||||
|
for (const legZ of [-0.3, 0.3]) {
|
||||||
|
const leg = new THREE.Mesh(legGeom, shellMat);
|
||||||
|
leg.position.set(side * 0.45, 0.15, legZ);
|
||||||
|
leg.rotation.z = side * 0.6;
|
||||||
|
this.registerFadeMesh(leg);
|
||||||
|
this.model.add(leg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Big claws (front, open/close)
|
||||||
|
const clawGeom = new THREE.SphereGeometry(0.22, 12, 10);
|
||||||
|
this.clawL = new THREE.Mesh(clawGeom, shellMat);
|
||||||
|
this.clawR = new THREE.Mesh(clawGeom, shellMat);
|
||||||
|
this.clawL.position.set(-0.45, 0.45, -0.5);
|
||||||
|
this.clawR.position.set(0.45, 0.45, -0.5);
|
||||||
|
this.clawL.scale.set(1.2, 0.7, 1.4);
|
||||||
|
this.clawR.scale.set(1.2, 0.7, 1.4);
|
||||||
|
this.clawL.castShadow = true;
|
||||||
|
this.clawR.castShadow = true;
|
||||||
|
this.registerFadeMesh(this.clawL);
|
||||||
|
this.registerFadeMesh(this.clawR);
|
||||||
|
this.model.add(this.clawL, this.clawR);
|
||||||
|
}
|
||||||
|
|
||||||
|
playAnim() {
|
||||||
|
// Procedural visuals only
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAnimation(dt: number) {
|
||||||
|
this.updateFadeDeath(dt, { duration: 0.8, sink: 0.4 });
|
||||||
|
}
|
||||||
|
|
||||||
|
update(dt: number, game: Game) {
|
||||||
|
this.time += dt;
|
||||||
|
|
||||||
|
// Claw pinch
|
||||||
|
const pinch = 1 + Math.sin(this.time * 4) * 0.35;
|
||||||
|
this.clawL.scale.set(1.2, 0.7 * pinch, 1.4);
|
||||||
|
this.clawR.scale.set(1.2, 0.7 * pinch, 1.4);
|
||||||
|
|
||||||
|
const toPlayer = new THREE.Vector3()
|
||||||
|
.subVectors(game.player.body.position, this.body.position);
|
||||||
|
toPlayer.y = 0;
|
||||||
|
const dist = toPlayer.length();
|
||||||
|
|
||||||
|
// Face the player
|
||||||
|
if (dist > 0.1) {
|
||||||
|
this.body.lookAt(
|
||||||
|
this.body.position.x + toPlayer.x,
|
||||||
|
this.body.position.y,
|
||||||
|
this.body.position.z + toPlayer.z
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strafe sideways around the player
|
||||||
|
if (dist > 3) {
|
||||||
|
const perp = new THREE.Vector3(-toPlayer.z, 0, toPlayer.x).normalize();
|
||||||
|
if (this.knockback > 0) {
|
||||||
|
this.knockback -= dt;
|
||||||
|
const away = toPlayer.clone().normalize().multiplyScalar(-this.speed);
|
||||||
|
this.body.position.x += away.x * dt;
|
||||||
|
this.body.position.z += away.z * dt;
|
||||||
|
} else {
|
||||||
|
// Flip side occasionally to circle
|
||||||
|
if (Math.random() < dt * 0.3) this.side *= -1;
|
||||||
|
this.body.position.x += perp.x * this.side * this.speed * dt;
|
||||||
|
this.body.position.z += perp.z * this.side * this.speed * dt;
|
||||||
|
}
|
||||||
|
game.clampToArena(this.body.position);
|
||||||
|
} else if (dist > 0.1) {
|
||||||
|
const dir = toPlayer.normalize();
|
||||||
|
if (this.knockback > 0) {
|
||||||
|
this.knockback -= dt;
|
||||||
|
this.body.position.x -= dir.x * this.speed * dt;
|
||||||
|
this.body.position.z -= dir.z * this.speed * dt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
takeDamage(
|
||||||
|
damage: number,
|
||||||
|
game: Game,
|
||||||
|
withKnockback = true,
|
||||||
|
sourcePos?: THREE.Vector3
|
||||||
|
) {
|
||||||
|
// Armored from the front: reduce damage coming from the facing side
|
||||||
|
if (sourcePos) {
|
||||||
|
const front = new THREE.Vector3(0, 0, -1)
|
||||||
|
.applyQuaternion(this.body.quaternion);
|
||||||
|
front.y = 0;
|
||||||
|
front.normalize();
|
||||||
|
const incoming = new THREE.Vector3()
|
||||||
|
.subVectors(sourcePos, this.body.position);
|
||||||
|
incoming.y = 0;
|
||||||
|
incoming.normalize();
|
||||||
|
if (front.dot(incoming) > 0.3) {
|
||||||
|
damage *= 0.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
super.takeDamage(damage, game, withKnockback, sourcePos);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onDeath(_game: Game) {
|
||||||
|
this.beginFadeDeath();
|
||||||
|
}
|
||||||
|
}
|
||||||
+81
-100
@@ -1,123 +1,97 @@
|
|||||||
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;
|
||||||
|
xp = 10;
|
||||||
|
contactDps = 25;
|
||||||
|
contactRadius = 2;
|
||||||
|
guaranteedDrop = false;
|
||||||
|
grace = 0;
|
||||||
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;
|
||||||
|
protected fadeOutModel: THREE.Group | null = null;
|
||||||
|
protected fadeMaterials: THREE.Material[] = [];
|
||||||
|
private dying = false;
|
||||||
|
private dieTime = 0;
|
||||||
|
|
||||||
constructor(_playerPos: THREE.Vector3) {
|
constructor() {
|
||||||
this.body = new THREE.Group();
|
this.body = new THREE.Group();
|
||||||
this.stopWalkSound = playLoopingSound('spiderwalking', 0.3);
|
}
|
||||||
|
|
||||||
if (spiderGLTF) {
|
abstract playAnim(name: string, loop?: boolean, speed?: number): void;
|
||||||
const model = cloneSkeleton(spiderGLTF.scene);
|
abstract updateAnimation(dt: number): void;
|
||||||
// Original game scaled the spider model by 0.5 (WalkingEnemy.setupModel)
|
abstract update(dt: number, game: Game): void;
|
||||||
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) {
|
canBeSlashHit(): boolean {
|
||||||
this.mixer = new THREE.AnimationMixer(model);
|
return true;
|
||||||
for (const clip of spiderGLTF.animations) {
|
}
|
||||||
const action = this.mixer.clipAction(clip);
|
|
||||||
this.clips.set(clip.name, action);
|
protected onDeath(game: Game) {
|
||||||
}
|
// Subclasses override for model-specific death behavior
|
||||||
this.playAnim('stand');
|
}
|
||||||
}
|
|
||||||
|
protected registerFadeMesh(mesh: THREE.Mesh) {
|
||||||
|
const mat = mesh.material as THREE.Material & {
|
||||||
|
transparent: boolean;
|
||||||
|
opacity: number;
|
||||||
|
};
|
||||||
|
mat.transparent = true;
|
||||||
|
this.fadeMaterials.push(mat);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected beginFadeDeath() {
|
||||||
|
this.dying = true;
|
||||||
|
this.dieTime = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected updateFadeDeath(
|
||||||
|
dt: number,
|
||||||
|
opts?: { duration?: number; shrink?: number; sink?: number }
|
||||||
|
): boolean {
|
||||||
|
if (!this.dying) return false;
|
||||||
|
const duration = opts?.duration ?? 0.9;
|
||||||
|
this.dieTime += dt;
|
||||||
|
const t = Math.min(this.dieTime / duration, 1);
|
||||||
|
const scale = Math.max(0.05, 1 - t * (opts?.shrink ?? 0.95));
|
||||||
|
if (this.fadeOutModel) {
|
||||||
|
this.fadeOutModel.scale.set(scale, scale, scale);
|
||||||
} else {
|
} else {
|
||||||
// Fallback
|
this.body.scale.set(scale, scale, scale);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
const sink = opts?.sink ?? 0;
|
||||||
|
if (sink > 0) {
|
||||||
playAnim(name: string, loop = true, speed = 1) {
|
this.body.position.y = Math.max(-1, this.body.position.y - sink * dt);
|
||||||
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 opacity = 1 - t;
|
||||||
const action = this.clips.get(name)!;
|
for (const mat of this.fadeMaterials) {
|
||||||
action.reset();
|
(mat as THREE.Material & { opacity: number }).opacity = opacity;
|
||||||
action.setLoop(loop ? THREE.LoopRepeat : THREE.LoopOnce, loop ? Infinity : 1);
|
}
|
||||||
action.clampWhenFinished = !loop;
|
return true;
|
||||||
action.setEffectiveTimeScale(speed);
|
|
||||||
action.play();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateAnimation(dt: number) {
|
takeDamage(
|
||||||
if (this.mixer) this.mixer.update(dt);
|
damage: number,
|
||||||
}
|
game: Game,
|
||||||
|
withKnockback = true,
|
||||||
takeDamage(damage: number, game: Game) {
|
sourcePos?: THREE.Vector3
|
||||||
if (this.dead) return;
|
) {
|
||||||
|
if (this.dead || this.grace > 0) return;
|
||||||
this.health -= damage;
|
this.health -= damage;
|
||||||
this.knockback = 0.5;
|
if (withKnockback) {
|
||||||
|
this.knockback = 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
playSound('damage', 0.7);
|
playSound('damage', 0.7);
|
||||||
|
game.onDamageDealt(damage);
|
||||||
|
|
||||||
if (this.health <= 0) {
|
if (this.health <= 0) {
|
||||||
this.die(game);
|
this.die(game);
|
||||||
@@ -127,11 +101,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(game);
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import type { Game } from './Game';
|
||||||
|
|
||||||
|
const SPEED = 12;
|
||||||
|
const HIT_RADIUS = 0.9;
|
||||||
|
|
||||||
|
export class EnemyProjectile {
|
||||||
|
body: THREE.Group;
|
||||||
|
velocity: THREE.Vector3;
|
||||||
|
damage: number;
|
||||||
|
lifetime = 6;
|
||||||
|
dead = false;
|
||||||
|
|
||||||
|
constructor(from: THREE.Vector3, target: THREE.Vector3, damage: number) {
|
||||||
|
this.damage = damage;
|
||||||
|
this.body = new THREE.Group();
|
||||||
|
this.body.position.copy(from);
|
||||||
|
|
||||||
|
this.velocity = new THREE.Vector3()
|
||||||
|
.subVectors(target, from)
|
||||||
|
.normalize()
|
||||||
|
.multiplyScalar(SPEED);
|
||||||
|
|
||||||
|
// Glowing poison blob
|
||||||
|
const glowMat = new THREE.MeshBasicMaterial({
|
||||||
|
color: 0x66ff44,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.55,
|
||||||
|
blending: THREE.AdditiveBlending,
|
||||||
|
depthWrite: false,
|
||||||
|
});
|
||||||
|
const glow = new THREE.Mesh(new THREE.SphereGeometry(0.4, 12, 12), glowMat);
|
||||||
|
this.body.add(glow);
|
||||||
|
|
||||||
|
const coreMat = new THREE.MeshBasicMaterial({
|
||||||
|
color: 0xaaff88,
|
||||||
|
blending: THREE.AdditiveBlending,
|
||||||
|
depthWrite: false,
|
||||||
|
});
|
||||||
|
const core = new THREE.Mesh(new THREE.SphereGeometry(0.2, 10, 10), coreMat);
|
||||||
|
this.body.add(core);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(dt: number, game: Game): boolean {
|
||||||
|
this.lifetime -= dt;
|
||||||
|
this.body.position.x += this.velocity.x * dt;
|
||||||
|
this.body.position.y += this.velocity.y * dt;
|
||||||
|
this.body.position.z += this.velocity.z * dt;
|
||||||
|
|
||||||
|
// Hit the player
|
||||||
|
if (game.player.health > 0) {
|
||||||
|
const dist = this.body.position.distanceTo(game.player.body.position);
|
||||||
|
if (dist < HIT_RADIUS) {
|
||||||
|
game.player.damage(this.damage);
|
||||||
|
this.dead = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.lifetime <= 0 || this.body.position.y < -1) {
|
||||||
|
this.dead = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
this.body.removeFromParent();
|
||||||
|
for (const child of [...this.body.children]) {
|
||||||
|
if (child instanceof THREE.Mesh) {
|
||||||
|
child.geometry.dispose();
|
||||||
|
(child.material as THREE.Material).dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ export class Fireball {
|
|||||||
exploding = false;
|
exploding = false;
|
||||||
timer = 0;
|
timer = 0;
|
||||||
damageDone = false;
|
damageDone = false;
|
||||||
|
damage = 100;
|
||||||
|
explosionRadius = 8;
|
||||||
private trailParticles: THREE.Points;
|
private trailParticles: THREE.Points;
|
||||||
private trailVelocities: Float32Array;
|
private trailVelocities: Float32Array;
|
||||||
private trailLives: Float32Array;
|
private trailLives: Float32Array;
|
||||||
|
|||||||
+285
@@ -0,0 +1,285 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import type { Game } from './Game';
|
||||||
|
import { Enemy } from './Enemy';
|
||||||
|
import { playSound } from './SoundManager';
|
||||||
|
|
||||||
|
const JUMP_DISTANCE = 6.5;
|
||||||
|
const JUMP_HEIGHT = 2.6;
|
||||||
|
const JUMP_DURATION = 0.65;
|
||||||
|
const CROUCH_TIME = 0.35;
|
||||||
|
const LAND_TIME = 0.3;
|
||||||
|
const PAUSE_TIME = 0.6;
|
||||||
|
|
||||||
|
type FrogState = 'crouch' | 'leap' | 'land' | 'pause';
|
||||||
|
|
||||||
|
export class Frog extends Enemy {
|
||||||
|
private model: THREE.Group;
|
||||||
|
private bodyMesh: THREE.Mesh;
|
||||||
|
private eyeL: THREE.Mesh;
|
||||||
|
private eyeR: THREE.Mesh;
|
||||||
|
private legs: THREE.Mesh[] = [];
|
||||||
|
private shadow: THREE.Mesh;
|
||||||
|
private shadowMat: THREE.MeshBasicMaterial;
|
||||||
|
private whiteMat: THREE.MeshToonMaterial;
|
||||||
|
private state: FrogState = 'pause';
|
||||||
|
private stateTimer = 0.4;
|
||||||
|
private jumpFrom = new THREE.Vector3();
|
||||||
|
private jumpTo = new THREE.Vector3();
|
||||||
|
private jumpProgress = 0;
|
||||||
|
private airborne = false;
|
||||||
|
private hopTime = 0;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.health = 35;
|
||||||
|
this.xp = 12;
|
||||||
|
|
||||||
|
this.model = new THREE.Group();
|
||||||
|
this.fadeOutModel = this.model;
|
||||||
|
this.body.add(this.model);
|
||||||
|
|
||||||
|
const greenMat = new THREE.MeshToonMaterial({ color: 0x55cc33 });
|
||||||
|
this.whiteMat = new THREE.MeshToonMaterial({ color: 0xf0fff0 });
|
||||||
|
|
||||||
|
// Squat body
|
||||||
|
this.bodyMesh = new THREE.Mesh(new THREE.SphereGeometry(0.55, 20, 16), greenMat);
|
||||||
|
this.bodyMesh.scale.set(1, 0.72, 1.1);
|
||||||
|
this.bodyMesh.position.y = 0.42;
|
||||||
|
this.bodyMesh.castShadow = true;
|
||||||
|
this.registerFadeMesh(this.bodyMesh);
|
||||||
|
this.model.add(this.bodyMesh);
|
||||||
|
|
||||||
|
// Belly
|
||||||
|
const belly = new THREE.Mesh(new THREE.SphereGeometry(0.4, 16, 12), this.whiteMat);
|
||||||
|
belly.scale.set(0.85, 0.5, 0.85);
|
||||||
|
belly.position.set(0, 0.3, -0.42);
|
||||||
|
this.registerFadeMesh(belly);
|
||||||
|
this.model.add(belly);
|
||||||
|
|
||||||
|
// Bulging eyes (face toward the player: -Z)
|
||||||
|
const eyeGeom = new THREE.SphereGeometry(0.17, 14, 12);
|
||||||
|
const pupilGeom = new THREE.SphereGeometry(0.08, 10, 8);
|
||||||
|
const pupilMat = new THREE.MeshToonMaterial({ color: 0x111122 });
|
||||||
|
this.eyeL = new THREE.Mesh(eyeGeom, this.whiteMat);
|
||||||
|
this.eyeR = new THREE.Mesh(eyeGeom, this.whiteMat);
|
||||||
|
this.eyeL.position.set(-0.24, 0.72, -0.28);
|
||||||
|
this.eyeR.position.set(0.24, 0.72, -0.28);
|
||||||
|
this.eyeL.castShadow = true;
|
||||||
|
this.eyeR.castShadow = true;
|
||||||
|
this.registerFadeMesh(this.eyeL);
|
||||||
|
this.registerFadeMesh(this.eyeR);
|
||||||
|
this.model.add(this.eyeL, this.eyeR);
|
||||||
|
|
||||||
|
for (const eye of [this.eyeL, this.eyeR]) {
|
||||||
|
const pupil = new THREE.Mesh(pupilGeom, pupilMat);
|
||||||
|
pupil.position.set(0, 0.02, -0.12);
|
||||||
|
this.registerFadeMesh(pupil);
|
||||||
|
eye.add(pupil);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Folded back legs
|
||||||
|
const legGeom = new THREE.SphereGeometry(0.16, 12, 10);
|
||||||
|
for (const side of [-1, 1]) {
|
||||||
|
const thigh = new THREE.Mesh(legGeom, greenMat);
|
||||||
|
thigh.scale.set(0.8, 1, 1.2);
|
||||||
|
thigh.position.set(side * 0.42, 0.25, 0.35);
|
||||||
|
thigh.rotation.y = side * 0.5;
|
||||||
|
thigh.castShadow = true;
|
||||||
|
this.registerFadeMesh(thigh);
|
||||||
|
this.model.add(thigh);
|
||||||
|
this.legs.push(thigh);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blob shadow on the ground
|
||||||
|
this.shadowMat = new THREE.MeshBasicMaterial({
|
||||||
|
color: 0x000000,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.25,
|
||||||
|
});
|
||||||
|
this.shadow = new THREE.Mesh(new THREE.CircleGeometry(0.5, 20), this.shadowMat);
|
||||||
|
this.shadow.rotation.x = -Math.PI / 2;
|
||||||
|
this.shadow.renderOrder = 998;
|
||||||
|
this.body.add(this.shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
playAnim() {
|
||||||
|
// No rigged animations, visuals are procedural
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAnimation(dt: number) {
|
||||||
|
this.updateFadeDeath(dt, { duration: 0.8, sink: 0.6 });
|
||||||
|
}
|
||||||
|
|
||||||
|
update(dt: number, game: Game) {
|
||||||
|
this.hopTime += dt;
|
||||||
|
|
||||||
|
// Knocked back: fall to the ground and slide away from the player
|
||||||
|
if (this.knockback > 0) {
|
||||||
|
this.knockback -= dt;
|
||||||
|
this.airborne = false;
|
||||||
|
this.state = 'pause';
|
||||||
|
this.stateTimer = 0.3;
|
||||||
|
const away = new THREE.Vector3()
|
||||||
|
.subVectors(this.body.position, game.player.body.position);
|
||||||
|
away.y = 0;
|
||||||
|
away.normalize().multiplyScalar(this.speed);
|
||||||
|
this.body.position.x += away.x * dt;
|
||||||
|
this.body.position.z += away.z * dt;
|
||||||
|
this.body.position.y = 0.1;
|
||||||
|
} else {
|
||||||
|
switch (this.state) {
|
||||||
|
case 'pause':
|
||||||
|
this.body.position.y = 0.1;
|
||||||
|
this.stateTimer -= dt;
|
||||||
|
if (this.stateTimer <= 0) {
|
||||||
|
this.state = 'crouch';
|
||||||
|
this.stateTimer = CROUCH_TIME;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'crouch': {
|
||||||
|
this.body.position.y = 0.1;
|
||||||
|
const target = game.player.body.position.clone();
|
||||||
|
const toPlayer = target.sub(this.body.position);
|
||||||
|
toPlayer.y = 0;
|
||||||
|
if (toPlayer.length() > JUMP_DISTANCE) {
|
||||||
|
toPlayer.normalize().multiplyScalar(JUMP_DISTANCE);
|
||||||
|
}
|
||||||
|
this.jumpFrom.copy(this.body.position);
|
||||||
|
this.jumpTo.set(
|
||||||
|
this.body.position.x + toPlayer.x,
|
||||||
|
0.1,
|
||||||
|
this.body.position.z + toPlayer.z
|
||||||
|
);
|
||||||
|
this.body.lookAt(this.jumpTo.x, this.body.position.y, this.jumpTo.z);
|
||||||
|
this.stateTimer -= dt;
|
||||||
|
if (this.stateTimer <= 0) {
|
||||||
|
this.state = 'leap';
|
||||||
|
this.jumpProgress = 0;
|
||||||
|
this.airborne = true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'leap': {
|
||||||
|
this.jumpProgress += dt / JUMP_DURATION;
|
||||||
|
const t = Math.min(this.jumpProgress, 1);
|
||||||
|
this.body.position.x = THREE.MathUtils.lerp(this.jumpFrom.x, this.jumpTo.x, t);
|
||||||
|
this.body.position.z = THREE.MathUtils.lerp(this.jumpFrom.z, this.jumpTo.z, t);
|
||||||
|
this.body.position.y = 0.1 + Math.sin(t * Math.PI) * JUMP_HEIGHT;
|
||||||
|
if (this.jumpProgress >= 1) {
|
||||||
|
this.airborne = false;
|
||||||
|
this.state = 'land';
|
||||||
|
this.stateTimer = LAND_TIME;
|
||||||
|
this.spawnDust(game.scene);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'land':
|
||||||
|
this.body.position.y = 0.1;
|
||||||
|
this.stateTimer -= dt;
|
||||||
|
if (this.stateTimer <= 0) {
|
||||||
|
this.state = 'pause';
|
||||||
|
this.stateTimer = PAUSE_TIME + Math.random() * 0.5;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Squash & stretch animation
|
||||||
|
let sy = 1;
|
||||||
|
let sxz = 1;
|
||||||
|
if (this.state === 'crouch') {
|
||||||
|
sy = 0.62;
|
||||||
|
sxz = 1.25;
|
||||||
|
} else if (this.state === 'leap') {
|
||||||
|
sy = 1.3;
|
||||||
|
sxz = 0.8;
|
||||||
|
} else if (this.state === 'land') {
|
||||||
|
sy = 0.7;
|
||||||
|
sxz = 1.25;
|
||||||
|
}
|
||||||
|
this.model.scale.set(sxz, sy, sxz);
|
||||||
|
this.eyeL.position.y = 0.72 * sy;
|
||||||
|
this.eyeR.position.y = 0.72 * sy;
|
||||||
|
|
||||||
|
// Leg twitch
|
||||||
|
for (let i = 0; i < this.legs.length; i++) {
|
||||||
|
const side = i === 0 ? -1 : 1;
|
||||||
|
this.legs[i].rotation.z = side * Math.sin(this.hopTime * 8) * 0.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shadow stays on the ground
|
||||||
|
this.shadow.position.y = 0.06 - this.body.position.y;
|
||||||
|
const shadowScale = 1 - (this.body.position.y - 0.1) / (JUMP_HEIGHT * 2);
|
||||||
|
this.shadowMat.opacity = 0.25 * Math.max(0.4, shadowScale);
|
||||||
|
|
||||||
|
game.clampToArena(this.body.position);
|
||||||
|
}
|
||||||
|
|
||||||
|
canBeSlashHit(): boolean {
|
||||||
|
return !this.airborne;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onDeath(game: Game) {
|
||||||
|
playSound('spawn', 0.4);
|
||||||
|
this.beginFadeDeath();
|
||||||
|
if (this.body.position.y > 0.15) {
|
||||||
|
this.body.position.y = 0.15;
|
||||||
|
}
|
||||||
|
void game;
|
||||||
|
}
|
||||||
|
|
||||||
|
private spawnDust(scene: THREE.Scene) {
|
||||||
|
const count = 10;
|
||||||
|
const positions = new Float32Array(count * 3);
|
||||||
|
const velocities: THREE.Vector3[] = [];
|
||||||
|
const lives = new Float32Array(count).fill(0.4);
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
positions[i * 3] = this.body.position.x;
|
||||||
|
positions[i * 3 + 1] = 0.15;
|
||||||
|
positions[i * 3 + 2] = this.body.position.z;
|
||||||
|
const angle = Math.random() * Math.PI * 2;
|
||||||
|
velocities.push(new THREE.Vector3(
|
||||||
|
Math.cos(angle) * 1.2,
|
||||||
|
Math.random() * 1.5,
|
||||||
|
Math.sin(angle) * 1.2
|
||||||
|
));
|
||||||
|
}
|
||||||
|
const geom = new THREE.BufferGeometry();
|
||||||
|
geom.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||||
|
const mat = new THREE.PointsMaterial({
|
||||||
|
color: 0xaa9988,
|
||||||
|
size: 0.15,
|
||||||
|
transparent: true,
|
||||||
|
depthWrite: false,
|
||||||
|
});
|
||||||
|
const points = new THREE.Points(geom, mat);
|
||||||
|
points.renderOrder = 999;
|
||||||
|
scene.add(points);
|
||||||
|
|
||||||
|
const startTime = performance.now();
|
||||||
|
const update = () => {
|
||||||
|
const elapsed = (performance.now() - startTime) / 1000;
|
||||||
|
if (elapsed > 0.5) {
|
||||||
|
points.removeFromParent();
|
||||||
|
geom.dispose();
|
||||||
|
mat.dispose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const arr = geom.getAttribute('position') as THREE.BufferAttribute;
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
lives[i] -= 0.016;
|
||||||
|
if (lives[i] <= 0) continue;
|
||||||
|
arr.array[i * 3] += velocities[i].x * 0.016;
|
||||||
|
arr.array[i * 3 + 1] += velocities[i].y * 0.016;
|
||||||
|
arr.array[i * 3 + 2] += velocities[i].z * 0.016;
|
||||||
|
}
|
||||||
|
arr.needsUpdate = true;
|
||||||
|
mat.opacity = Math.max(0, 1 - elapsed / 0.5);
|
||||||
|
requestAnimationFrame(update);
|
||||||
|
};
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
}
|
||||||
+491
-68
@@ -1,15 +1,30 @@
|
|||||||
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 { Frog } from './Frog';
|
||||||
|
import { Bat } from './Bat';
|
||||||
|
import { BeeSwarm } from './BeeSwarm';
|
||||||
|
import { Slime } from './Slime';
|
||||||
|
import { Brute } from './Brute';
|
||||||
|
import { Boss } from './Boss';
|
||||||
|
import { Crab } from './Crab';
|
||||||
|
import { Turret } from './Turret';
|
||||||
|
import { EnemyProjectile } from './EnemyProjectile';
|
||||||
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, isInSlashArc } from './SwordTrail';
|
||||||
|
import { SkillSystem, getSkill, type SkillOffer } from './Skills';
|
||||||
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' | 'levelup';
|
||||||
|
|
||||||
export class Game {
|
export class Game {
|
||||||
scene: THREE.Scene;
|
scene: THREE.Scene;
|
||||||
camera: THREE.PerspectiveCamera;
|
camera: THREE.PerspectiveCamera;
|
||||||
@@ -20,6 +35,16 @@ export class Game {
|
|||||||
enemies: Enemy[] = [];
|
enemies: Enemy[] = [];
|
||||||
fireballs: Fireball[] = [];
|
fireballs: Fireball[] = [];
|
||||||
crosses: Cross[] = [];
|
crosses: Cross[] = [];
|
||||||
|
slashEffects: SlashEffect[] = [];
|
||||||
|
projectiles: EnemyProjectile[] = [];
|
||||||
|
|
||||||
|
state: GameState = 'menu';
|
||||||
|
skillSystem: SkillSystem = new SkillSystem(0);
|
||||||
|
private seed: number | null = null;
|
||||||
|
private offers: SkillOffer[] = [];
|
||||||
|
private selectedOffer = -1;
|
||||||
|
private pendingPicks = 0;
|
||||||
|
private gameOverAt = 0;
|
||||||
|
|
||||||
keys: Set<string> = new Set();
|
keys: Set<string> = new Set();
|
||||||
mouseButtons: Set<number> = new Set();
|
mouseButtons: Set<number> = new Set();
|
||||||
@@ -31,7 +56,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;
|
||||||
@@ -46,11 +70,30 @@ export class Game {
|
|||||||
private uiWaveLabel!: HTMLElement;
|
private uiWaveLabel!: HTMLElement;
|
||||||
private uiKillsLabel!: HTMLElement;
|
private uiKillsLabel!: HTMLElement;
|
||||||
private uiFireballCD!: HTMLElement;
|
private uiFireballCD!: HTMLElement;
|
||||||
|
private uiFireballContainer!: HTMLElement;
|
||||||
private uiSwordCD!: HTMLElement;
|
private uiSwordCD!: HTMLElement;
|
||||||
private uiTeleportCD!: HTMLElement;
|
private uiTeleportCD!: HTMLElement;
|
||||||
|
private uiTeleportContainer!: HTMLElement;
|
||||||
|
private uiLightningCD!: HTMLElement;
|
||||||
|
private uiLightningContainer!: HTMLElement;
|
||||||
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 uiSeedInput!: HTMLInputElement;
|
||||||
|
private uiLevelLabel!: HTMLElement;
|
||||||
|
private uiSeedLabel!: HTMLElement;
|
||||||
|
private uiXpBarFill!: HTMLElement;
|
||||||
|
private uiXpBarLabel!: HTMLElement;
|
||||||
|
private uiShieldBar!: HTMLElement;
|
||||||
|
private uiShieldBarBg!: HTMLElement;
|
||||||
|
private uiShieldText!: HTMLElement;
|
||||||
|
private uiLevelUp!: HTMLElement;
|
||||||
|
private uiConfirmBtn!: HTMLButtonElement;
|
||||||
|
private uiOfferTitles: HTMLElement[] = [];
|
||||||
|
private uiOfferDescs: HTMLElement[] = [];
|
||||||
private mouseOnCanvas = false;
|
private mouseOnCanvas = false;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -110,32 +153,94 @@ export class Game {
|
|||||||
this.uiWaveLabel = document.getElementById('wave-label')!;
|
this.uiWaveLabel = document.getElementById('wave-label')!;
|
||||||
this.uiKillsLabel = document.getElementById('kills-label')!;
|
this.uiKillsLabel = document.getElementById('kills-label')!;
|
||||||
this.uiFireballCD = document.querySelector('#fireball-cooldown .cooldown-fill')!;
|
this.uiFireballCD = document.querySelector('#fireball-cooldown .cooldown-fill')!;
|
||||||
|
this.uiFireballContainer = document.getElementById('fireball-cooldown')!;
|
||||||
this.uiSwordCD = document.querySelector('#sword-cooldown .cooldown-fill')!;
|
this.uiSwordCD = document.querySelector('#sword-cooldown .cooldown-fill')!;
|
||||||
this.uiTeleportCD = document.querySelector('#teleport-cooldown .cooldown-fill')!;
|
this.uiTeleportCD = document.querySelector('#teleport-cooldown .cooldown-fill')!;
|
||||||
|
this.uiTeleportContainer = document.getElementById('teleport-cooldown')!;
|
||||||
|
this.uiLightningCD = document.querySelector('#lightning-cooldown .cooldown-fill')!;
|
||||||
|
this.uiLightningContainer = document.getElementById('lightning-cooldown')!;
|
||||||
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')!;
|
||||||
|
this.uiSeedInput = document.getElementById('seed-input') as HTMLInputElement;
|
||||||
|
this.uiLevelLabel = document.getElementById('level-label')!;
|
||||||
|
this.uiSeedLabel = document.getElementById('seed-label')!;
|
||||||
|
this.uiXpBarFill = document.getElementById('xp-bar-fill')!;
|
||||||
|
this.uiXpBarLabel = document.getElementById('xp-bar-label')!;
|
||||||
|
this.uiShieldBar = document.getElementById('shield-bar')!;
|
||||||
|
this.uiShieldBarBg = document.getElementById('shield-bar-bg')!;
|
||||||
|
this.uiShieldText = document.getElementById('shield-text')!;
|
||||||
|
this.uiLevelUp = document.getElementById('level-up')!;
|
||||||
|
this.uiConfirmBtn = document.getElementById('btn-confirm') as HTMLButtonElement;
|
||||||
|
this.uiConfirmBtn.addEventListener('click', () => this.confirmOffer());
|
||||||
|
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const card = document.getElementById(`offer-${i}`)!;
|
||||||
|
card.addEventListener('click', () => this.selectOffer(i));
|
||||||
|
this.uiOfferTitles.push(document.getElementById(`offer-title-${i}`)!);
|
||||||
|
this.uiOfferDescs.push(document.getElementById(`offer-desc-${i}`)!);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('btn-start')!.addEventListener('click', () => this.startGame());
|
||||||
|
document.getElementById('btn-restart')!.addEventListener('click', () => this.restart());
|
||||||
|
document.getElementById('btn-menu')!.addEventListener('click', () => this.showMenu());
|
||||||
|
|
||||||
|
// Diagnostics: detect a full page reload that wiped a running game
|
||||||
|
if (sessionStorage.getItem('wackelpeter-run') === '1') {
|
||||||
|
console.warn('[Wackelpeter] Seite wurde während eines Laufs neu geladen!');
|
||||||
|
sessionStorage.removeItem('wackelpeter-run');
|
||||||
|
}
|
||||||
|
window.addEventListener('beforeunload', () => {
|
||||||
|
console.log('[Wackelpeter] beforeunload (Seite wird neu geladen/geschlossen)');
|
||||||
|
});
|
||||||
|
|
||||||
const canvas = this.renderer.domElement;
|
const canvas = this.renderer.domElement;
|
||||||
canvas.addEventListener('mouseenter', () => this.mouseOnCanvas = true);
|
canvas.addEventListener('mouseenter', () => this.mouseOnCanvas = true);
|
||||||
canvas.addEventListener('mouseleave', () => this.mouseOnCanvas = false);
|
canvas.addEventListener('mouseleave', () => this.mouseOnCanvas = false);
|
||||||
canvas.addEventListener('contextmenu', e => e.preventDefault());
|
window.addEventListener('contextmenu', e => e.preventDefault());
|
||||||
|
}
|
||||||
|
|
||||||
|
private logState(next: GameState, reason: string) {
|
||||||
|
console.log(`[Wackelpeter] state: ${this.state} → ${next} (${reason})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 === 'KeyQ' && this.state === 'playing') {
|
||||||
|
this.player.skillLightning.skill1(this.groundPoint, this.player);
|
||||||
|
}
|
||||||
|
if (this.state === 'levelup') {
|
||||||
|
if (e.code === 'Digit1' || e.code === 'Numpad1') this.selectOffer(0);
|
||||||
|
else if (e.code === 'Digit2' || e.code === 'Numpad2') this.selectOffer(1);
|
||||||
|
else if (e.code === 'Digit3' || e.code === 'Numpad3') this.selectOffer(2);
|
||||||
|
else if (e.code === 'Enter') this.confirmOffer();
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.code === 'Enter' || e.code === 'Space') {
|
||||||
|
if (this.state === 'menu') this.startGame();
|
||||||
|
else if (
|
||||||
|
this.state === 'gameover' &&
|
||||||
|
performance.now() - this.gameOverAt > 1500
|
||||||
|
) {
|
||||||
|
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,13 +262,11 @@ 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);
|
|
||||||
} else if (button === 1) {
|
|
||||||
this.player.switchHands();
|
|
||||||
} else if (button === 2) {
|
|
||||||
this.player.rightHandWeapon.skill1(this.groundPoint, this.player);
|
this.player.rightHandWeapon.skill1(this.groundPoint, this.player);
|
||||||
|
} else if (button === 2) {
|
||||||
|
this.player.leftHandWeapon.skill1(this.groundPoint, this.player);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,15 +389,287 @@ export class Game {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
startGame() {
|
||||||
|
if (this.state === 'playing' || this.state === 'levelup') return;
|
||||||
|
if (this.seed === null) {
|
||||||
|
const input = this.uiSeedInput.value.trim();
|
||||||
|
if (input !== '' && !Number.isNaN(Number(input))) {
|
||||||
|
this.seed = Number(input) >>> 0;
|
||||||
|
} else {
|
||||||
|
this.seed = Math.floor(Math.random() * 1000000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.resetWorld();
|
||||||
|
this.logState('playing', 'startGame');
|
||||||
|
this.state = 'playing';
|
||||||
|
sessionStorage.setItem('wackelpeter-run', '1');
|
||||||
|
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';
|
||||||
|
this.blurFocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
private blurFocus() {
|
||||||
|
const el = document.activeElement;
|
||||||
|
if (el instanceof HTMLElement) el.blur();
|
||||||
|
}
|
||||||
|
|
||||||
|
restart() {
|
||||||
|
this.startGame();
|
||||||
|
}
|
||||||
|
|
||||||
|
showMenu() {
|
||||||
|
this.resetWorld();
|
||||||
|
this.logState('menu', 'showMenu');
|
||||||
|
this.state = 'menu';
|
||||||
|
this.seed = null;
|
||||||
|
sessionStorage.removeItem('wackelpeter-run');
|
||||||
|
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';
|
||||||
|
this.blurFocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
private gameOver() {
|
||||||
|
this.logState('gameover', 'gameOver');
|
||||||
|
this.state = 'gameover';
|
||||||
|
this.gameOverAt = performance.now();
|
||||||
|
this.player.die();
|
||||||
|
this.blurFocus();
|
||||||
|
this.uiGameOver.style.opacity = '1';
|
||||||
|
this.uiGameOverStats.textContent = `Wave ${this.spawns} · Kills: ${this.killed} · Level ${this.skillSystem.level}`;
|
||||||
|
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 p of this.projectiles) p.dispose();
|
||||||
|
this.projectiles = [];
|
||||||
|
|
||||||
|
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.pendingPicks = 0;
|
||||||
|
this.hideLevelUp();
|
||||||
|
this.skillSystem = new SkillSystem(this.seed ?? 0);
|
||||||
|
|
||||||
|
this.player.reset();
|
||||||
|
this.player.applySkillSystem(this.skillSystem);
|
||||||
|
this.syncSkillVisibility();
|
||||||
|
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.player.skillLightning.cooldown1 = 0;
|
||||||
|
this.player.skillLightning.cooldown2 = 0;
|
||||||
|
this.player.skillLightning.cooldown3 = 0;
|
||||||
|
|
||||||
|
this.updateUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
onDamageDealt(damage: number) {
|
||||||
|
if (this.state === 'playing' && this.player.stats.lifesteal > 0) {
|
||||||
|
this.player.heal(damage * this.player.stats.lifesteal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private openLevelUp() {
|
||||||
|
this.offers = this.skillSystem.rollOffers();
|
||||||
|
if (this.offers.length === 0) {
|
||||||
|
// Everything maxed out: no picks left, just continue playing
|
||||||
|
this.pendingPicks = 0;
|
||||||
|
this.hideLevelUp();
|
||||||
|
this.logState('playing', 'openLevelUp (keine Angebote)');
|
||||||
|
this.state = 'playing';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.logState('levelup', 'openLevelUp');
|
||||||
|
this.state = 'levelup';
|
||||||
|
this.selectedOffer = -1;
|
||||||
|
this.blurFocus();
|
||||||
|
playSound('spawn', 0.6);
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const title = this.uiOfferTitles[i];
|
||||||
|
const desc = this.uiOfferDescs[i];
|
||||||
|
const card = document.getElementById(`offer-${i}`)!;
|
||||||
|
if (i < this.offers.length) {
|
||||||
|
const offer = this.offers[i];
|
||||||
|
const skill = getSkill(offer.id);
|
||||||
|
const isNew = offer.nextLevel === 1;
|
||||||
|
title.textContent = `${skill.name}${isNew ? ' (NEU)' : ` · Stufe ${offer.nextLevel}`}`;
|
||||||
|
desc.textContent = skill.describe(offer.nextLevel);
|
||||||
|
card.style.display = '';
|
||||||
|
card.classList.remove('selected');
|
||||||
|
} else {
|
||||||
|
card.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.uiConfirmBtn.disabled = true;
|
||||||
|
this.uiLevelUp.style.opacity = '1';
|
||||||
|
this.uiLevelUp.style.pointerEvents = 'auto';
|
||||||
|
}
|
||||||
|
|
||||||
|
private hideLevelUp() {
|
||||||
|
this.uiLevelUp.style.opacity = '0';
|
||||||
|
this.uiLevelUp.style.pointerEvents = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
private selectOffer(index: number) {
|
||||||
|
if (this.state !== 'levelup' || index >= this.offers.length) return;
|
||||||
|
this.selectedOffer = index;
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const card = document.getElementById(`offer-${i}`)!;
|
||||||
|
card.classList.toggle('selected', i === index);
|
||||||
|
}
|
||||||
|
this.uiConfirmBtn.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private confirmOffer() {
|
||||||
|
if (this.state !== 'levelup' || this.selectedOffer < 0) return;
|
||||||
|
const offer = this.offers[this.selectedOffer];
|
||||||
|
this.selectedOffer = -1;
|
||||||
|
this.skillSystem.apply(offer.id);
|
||||||
|
this.player.applySkillSystem(this.skillSystem);
|
||||||
|
this.syncSkillVisibility();
|
||||||
|
this.blurFocus();
|
||||||
|
|
||||||
|
if (this.pendingPicks > 0) {
|
||||||
|
this.pendingPicks--;
|
||||||
|
this.openLevelUp();
|
||||||
|
} else if (this.skillSystem.hasPendingLevelUp()) {
|
||||||
|
this.openLevelUp();
|
||||||
|
} else {
|
||||||
|
this.hideLevelUp();
|
||||||
|
this.logState('playing', 'confirmOffer');
|
||||||
|
this.state = 'playing';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private syncSkillVisibility() {
|
||||||
|
const s = this.skillSystem;
|
||||||
|
this.player.leftHandWeapon.mesh.visible = s.getLevel('fireball') > 0;
|
||||||
|
this.uiFireballContainer.style.display = s.getLevel('fireball') > 0 ? '' : 'none';
|
||||||
|
this.uiTeleportContainer.style.display = s.getLevel('teleport') > 0 ? '' : 'none';
|
||||||
|
this.uiLightningContainer.style.display = s.getLevel('chainlightning') > 0 ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
private spawnEnemy() {
|
private spawnEnemy() {
|
||||||
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, 0.1, z);
|
const weights = this.enemyWeights(this.spawns);
|
||||||
|
let total = 0;
|
||||||
|
for (const w of weights.values()) total += w;
|
||||||
|
let roll = Math.random() * total;
|
||||||
|
let kind = 'spider';
|
||||||
|
for (const [k, w] of weights) {
|
||||||
|
roll -= w;
|
||||||
|
if (roll <= 0) {
|
||||||
|
kind = k;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (kind) {
|
||||||
|
case 'spider': this.spawnEnemyInstance(new Spider(), x, z, 0.1); break;
|
||||||
|
case 'ghost': this.spawnEnemyInstance(new Ghost(), x, z, 1.0); break;
|
||||||
|
case 'frog': this.spawnEnemyInstance(new Frog(), x, z, 0.1); break;
|
||||||
|
case 'bat': this.spawnEnemyInstance(new Bat(), x, z, 1.5); break;
|
||||||
|
case 'bee': this.spawnEnemyInstance(new BeeSwarm(), x, z, 1.2); break;
|
||||||
|
case 'slime': this.spawnEnemyInstance(new Slime(), x, z, 0.1); break;
|
||||||
|
case 'brute': this.spawnEnemyInstance(new Brute(), x, z, 0.15); break;
|
||||||
|
case 'crab': this.spawnEnemyInstance(new Crab(), x, z, 0.1); break;
|
||||||
|
case 'turret': this.spawnEnemyInstance(new Turret(), x, z, 0.1); break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enemyWeights(wave: number): Map<string, number> {
|
||||||
|
const w = new Map<string, number>();
|
||||||
|
if (wave >= 7) {
|
||||||
|
w.set('spider', 25).set('ghost', 15).set('frog', 15).set('slime', 10)
|
||||||
|
.set('bat', 10).set('turret', 10).set('crab', 10).set('bee', 5);
|
||||||
|
} else if (wave >= 6) {
|
||||||
|
w.set('spider', 35).set('ghost', 20).set('frog', 15).set('slime', 10)
|
||||||
|
.set('bat', 10).set('turret', 10);
|
||||||
|
} else if (wave >= 5) {
|
||||||
|
w.set('spider', 45).set('ghost', 20).set('frog', 15).set('slime', 10)
|
||||||
|
.set('bat', 10);
|
||||||
|
} else if (wave >= 4) {
|
||||||
|
w.set('spider', 55).set('ghost', 25).set('frog', 20);
|
||||||
|
} else if (wave >= 3) {
|
||||||
|
w.set('spider', 75).set('ghost', 25);
|
||||||
|
} else {
|
||||||
|
w.set('spider', 100);
|
||||||
|
}
|
||||||
|
return w;
|
||||||
|
}
|
||||||
|
|
||||||
|
spawnEnemyInstance(enemy: Enemy, x: number, z: number, y: number) {
|
||||||
|
enemy.body.position.set(x, y, z);
|
||||||
this.scene.add(enemy.body);
|
this.scene.add(enemy.body);
|
||||||
this.enemies.push(enemy);
|
this.enemies.push(enemy);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
spawnEnemyProjectile(from: THREE.Vector3, target: THREE.Vector3, damage: number) {
|
||||||
|
const projectile = new EnemyProjectile(from, target, damage);
|
||||||
|
this.scene.add(projectile.body);
|
||||||
|
this.projectiles.push(projectile);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroyProjectilesInArc(pos: THREE.Vector3, aim: THREE.Vector3, range: number) {
|
||||||
|
for (let i = this.projectiles.length - 1; i >= 0; i--) {
|
||||||
|
const p = this.projectiles[i];
|
||||||
|
if (isInSlashArc(pos, aim, p.body.position, range)) {
|
||||||
|
p.dispose();
|
||||||
|
this.projectiles.splice(i, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private spawnCross(position: THREE.Vector3) {
|
private spawnCross(position: THREE.Vector3) {
|
||||||
if (Math.random() > 0.9) {
|
if (Math.random() > 0.9) {
|
||||||
const cross = new Cross(position.clone());
|
const cross = new Cross(position.clone());
|
||||||
@@ -319,7 +694,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;
|
||||||
}
|
}
|
||||||
@@ -338,7 +713,7 @@ export class Game {
|
|||||||
if (this.keys.has('KeyA') || this.keys.has('ArrowLeft')) moveDir.add(camLeft);
|
if (this.keys.has('KeyA') || this.keys.has('ArrowLeft')) moveDir.add(camLeft);
|
||||||
if (this.keys.has('KeyD') || this.keys.has('ArrowRight')) moveDir.sub(camLeft);
|
if (this.keys.has('KeyD') || this.keys.has('ArrowRight')) moveDir.sub(camLeft);
|
||||||
|
|
||||||
let speed = 7.5;
|
let speed = this.player.stats.moveSpeed;
|
||||||
let isMoving = false;
|
let isMoving = false;
|
||||||
if (moveDir.length() > 0) {
|
if (moveDir.length() > 0) {
|
||||||
moveDir.normalize();
|
moveDir.normalize();
|
||||||
@@ -379,45 +754,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -434,8 +782,8 @@ export class Game {
|
|||||||
for (const enemy of this.enemies) {
|
for (const enemy of this.enemies) {
|
||||||
if (enemy.dead) continue;
|
if (enemy.dead) continue;
|
||||||
const dist = enemy.body.position.distanceTo(fb.body.position);
|
const dist = enemy.body.position.distanceTo(fb.body.position);
|
||||||
if (dist < 8) {
|
if (dist < fb.explosionRadius) {
|
||||||
enemy.takeDamage(100, this);
|
enemy.takeDamage(fb.damage, this, true, fb.body.position);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -466,15 +814,37 @@ export class Game {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private updateUI() {
|
private updateUI() {
|
||||||
const healthPct = this.player.health / this.player.MAX_HEALTH;
|
const maxHealth = this.player.stats.maxHealth;
|
||||||
|
const healthPct = this.player.health / maxHealth;
|
||||||
this.uiHealthBar.style.width = `${Math.max(0, healthPct * 250)}px`;
|
this.uiHealthBar.style.width = `${Math.max(0, healthPct * 250)}px`;
|
||||||
this.uiHealthText.textContent = `${Math.ceil(this.player.health)}%`;
|
this.uiHealthText.textContent = `${Math.ceil(this.player.health)}`;
|
||||||
|
|
||||||
|
const shieldMax = this.player.stats.shield;
|
||||||
|
this.uiShieldBarBg.style.display = shieldMax > 0 ? '' : 'none';
|
||||||
|
this.uiShieldBar.style.display = shieldMax > 0 ? '' : 'none';
|
||||||
|
this.uiShieldText.style.display = shieldMax > 0 ? '' : 'none';
|
||||||
|
if (shieldMax > 0) {
|
||||||
|
const shieldPct = this.player.shieldHp / shieldMax;
|
||||||
|
this.uiShieldBar.style.width = `${Math.max(0, shieldPct * 250)}px`;
|
||||||
|
this.uiShieldText.textContent = `${Math.ceil(this.player.shieldHp)}`;
|
||||||
|
}
|
||||||
|
|
||||||
this.uiWaveLabel.textContent = `Wave ${this.spawns}`;
|
this.uiWaveLabel.textContent = `Wave ${this.spawns}`;
|
||||||
this.uiKillsLabel.textContent = `Kills: ${this.killed}`;
|
this.uiKillsLabel.textContent = `Kills: ${this.killed}`;
|
||||||
|
this.uiLevelLabel.textContent = `Level ${this.skillSystem.level}`;
|
||||||
|
this.uiSeedLabel.textContent = this.seed === null
|
||||||
|
? ''
|
||||||
|
: `Seed: ${this.seed}`;
|
||||||
|
|
||||||
|
const xpPct = this.skillSystem.xpProgress();
|
||||||
|
this.uiXpBarFill.style.width = `${Math.min(1, xpPct) * 100}%`;
|
||||||
|
this.uiXpBarLabel.textContent = this.skillSystem.hasAnyUpgradeLeft()
|
||||||
|
? `XP: ${Math.floor(this.skillSystem.xp)}/${this.skillSystem.xpNeeded()}`
|
||||||
|
: 'MAX';
|
||||||
|
|
||||||
const sword = this.player.rightHandWeapon as Sword;
|
const sword = this.player.rightHandWeapon as Sword;
|
||||||
const staff = this.player.leftHandWeapon as Staff;
|
const staff = this.player.leftHandWeapon as Staff;
|
||||||
|
const lightning = this.player.skillLightning;
|
||||||
|
|
||||||
const fbPct = Math.max(0, (staff.COOLDOWN1_TIME - staff.cooldown1) / staff.COOLDOWN1_TIME);
|
const fbPct = Math.max(0, (staff.COOLDOWN1_TIME - staff.cooldown1) / staff.COOLDOWN1_TIME);
|
||||||
this.uiFireballCD.style.width = `${fbPct * 100}%`;
|
this.uiFireballCD.style.width = `${fbPct * 100}%`;
|
||||||
@@ -485,15 +855,15 @@ export class Game {
|
|||||||
const tpPct = Math.max(0, (staff.COOLDOWN2_TIME - staff.cooldown2) / staff.COOLDOWN2_TIME);
|
const tpPct = Math.max(0, (staff.COOLDOWN2_TIME - staff.cooldown2) / staff.COOLDOWN2_TIME);
|
||||||
this.uiTeleportCD.style.width = `${tpPct * 100}%`;
|
this.uiTeleportCD.style.width = `${tpPct * 100}%`;
|
||||||
|
|
||||||
|
const lnPct = Math.max(0, (lightning.COOLDOWN1_TIME - lightning.cooldown1) / lightning.COOLDOWN1_TIME);
|
||||||
|
this.uiLightningCD.style.width = `${lnPct * 100}%`;
|
||||||
|
|
||||||
const wavePct = this.spawnTimer / SPAWN_TIME;
|
const wavePct = this.spawnTimer / SPAWN_TIME;
|
||||||
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,28 +878,61 @@ 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++;
|
||||||
for (let i = 0; i < this.spawns; i++) {
|
if (this.spawns % 5 === 0) {
|
||||||
this.spawnEnemy();
|
const x = (Math.random() - 0.5) * 80;
|
||||||
|
const z = (Math.random() - 0.5) * 80;
|
||||||
|
this.spawnEnemyInstance(new Boss(), x, z, 0.15);
|
||||||
|
playSound('spawn', 0.8);
|
||||||
|
} else {
|
||||||
|
for (let i = 0; i < this.spawns; i++) {
|
||||||
|
this.spawnEnemy();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this.spawnTimer = 0;
|
this.spawnTimer = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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;
|
const thorns = this.player.stats.thornDamage;
|
||||||
const dist = enemy.body.position.distanceTo(this.player.body.position);
|
for (const enemy of this.enemies) {
|
||||||
if (dist < 2) {
|
if (enemy.dead) continue;
|
||||||
this.player.health -= 25 * dt;
|
const dist = enemy.body.position.distanceTo(this.player.body.position);
|
||||||
|
if (dist < enemy.contactRadius) {
|
||||||
|
this.player.damage(enemy.contactDps * dt);
|
||||||
|
if (thorns > 0) {
|
||||||
|
enemy.takeDamage(thorns * dt, this, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.player.updatePassives(dt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enemy projectiles
|
||||||
|
if (this.state === 'playing') {
|
||||||
|
for (let i = this.projectiles.length - 1; i >= 0; i--) {
|
||||||
|
const p = this.projectiles[i];
|
||||||
|
const alive = p.update(dt, this);
|
||||||
|
if (!alive || p.dead) {
|
||||||
|
p.dispose();
|
||||||
|
this.projectiles.splice(i, 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -537,17 +940,20 @@ 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update cooldowns
|
// Update cooldowns
|
||||||
this.player.rightHandWeapon.reduceCooldowns(dt);
|
this.player.rightHandWeapon.reduceCooldowns(dt);
|
||||||
this.player.leftHandWeapon.reduceCooldowns(dt);
|
this.player.leftHandWeapon.reduceCooldowns(dt);
|
||||||
|
this.player.skillLightning.reduceCooldowns(dt);
|
||||||
|
|
||||||
// Camera follows player
|
// Camera follows player
|
||||||
this.camera.position.set(
|
this.camera.position.set(
|
||||||
@@ -580,6 +986,23 @@ export class Game {
|
|||||||
removeEnemy(enemy: Enemy) {
|
removeEnemy(enemy: Enemy) {
|
||||||
enemy.dead = true;
|
enemy.dead = true;
|
||||||
this.killed++;
|
this.killed++;
|
||||||
this.spawnCross(enemy.body.position);
|
if (enemy.guaranteedDrop) {
|
||||||
|
const cross = new Cross(enemy.body.position.clone());
|
||||||
|
this.scene.add(cross.body);
|
||||||
|
this.crosses.push(cross);
|
||||||
|
} else {
|
||||||
|
this.spawnCross(enemy.body.position);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.state === 'playing' || this.state === 'levelup') {
|
||||||
|
const leveled = this.skillSystem.addXp(enemy.xp);
|
||||||
|
if (leveled) {
|
||||||
|
if (this.state === 'playing') {
|
||||||
|
this.openLevelUp();
|
||||||
|
} else {
|
||||||
|
this.pendingPicks++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
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 fadeDying = false;
|
||||||
|
private fadeDieTime = 0;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.health = 40;
|
||||||
|
this.speed = 7.5;
|
||||||
|
this.xp = 15;
|
||||||
|
|
||||||
|
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.fadeDying) return;
|
||||||
|
this.fadeDieTime += dt;
|
||||||
|
const t = Math.min(this.fadeDieTime / 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(_game: Game) {
|
||||||
|
this.fadeDying = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
super.dispose();
|
||||||
|
this.whiteMat.dispose();
|
||||||
|
this.darkMat.dispose();
|
||||||
|
this.blushMat.dispose();
|
||||||
|
this.shadowMat.dispose();
|
||||||
|
this.shadow.geometry.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
+93
-17
@@ -1,19 +1,46 @@
|
|||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { Sword } from './Sword';
|
import { Sword } from './Sword';
|
||||||
import { Staff } from './Staff';
|
import { Staff } from './Staff';
|
||||||
|
import { ChainLightning } from './ChainLightning';
|
||||||
import { loadGLB } from './MeshLoader';
|
import { loadGLB } from './MeshLoader';
|
||||||
import type { Game } from './Game';
|
import type { Game } from './Game';
|
||||||
|
import type { SkillSystem } from './Skills';
|
||||||
|
|
||||||
|
export interface PlayerStats {
|
||||||
|
maxHealth: number;
|
||||||
|
regen: number;
|
||||||
|
lifesteal: number;
|
||||||
|
shield: number;
|
||||||
|
shieldRechargeDelay: number;
|
||||||
|
moveSpeed: number;
|
||||||
|
swordDamage: number;
|
||||||
|
swordRange: number;
|
||||||
|
thornDamage: number;
|
||||||
|
}
|
||||||
|
|
||||||
export class Player {
|
export class Player {
|
||||||
body: THREE.Group;
|
body: THREE.Group;
|
||||||
leftHandWeapon: Staff;
|
leftHandWeapon: Staff;
|
||||||
rightHandWeapon: Sword;
|
rightHandWeapon: Sword;
|
||||||
|
skillLightning: ChainLightning;
|
||||||
velocity = new THREE.Vector3();
|
velocity = new THREE.Vector3();
|
||||||
health = 100;
|
health = 100;
|
||||||
MAX_HEALTH = 100;
|
|
||||||
game!: Game;
|
game!: Game;
|
||||||
jumpVelocity = 0;
|
jumpVelocity = 0;
|
||||||
onGround = true;
|
onGround = true;
|
||||||
|
shieldHp = 0;
|
||||||
|
shieldTimer = 0;
|
||||||
|
stats: PlayerStats = {
|
||||||
|
maxHealth: 100,
|
||||||
|
regen: 0,
|
||||||
|
lifesteal: 0,
|
||||||
|
shield: 0,
|
||||||
|
shieldRechargeDelay: 8,
|
||||||
|
moveSpeed: 7.5,
|
||||||
|
swordDamage: 50,
|
||||||
|
swordRange: 2.2,
|
||||||
|
thornDamage: 0,
|
||||||
|
};
|
||||||
mixer!: THREE.AnimationMixer;
|
mixer!: THREE.AnimationMixer;
|
||||||
private clips: Map<string, THREE.AnimationAction> = new Map();
|
private clips: Map<string, THREE.AnimationAction> = new Map();
|
||||||
private currentAnim = '';
|
private currentAnim = '';
|
||||||
@@ -64,6 +91,8 @@ export class Player {
|
|||||||
// Weapons
|
// Weapons
|
||||||
this.leftHandWeapon = new Staff();
|
this.leftHandWeapon = new Staff();
|
||||||
this.rightHandWeapon = new Sword();
|
this.rightHandWeapon = new Sword();
|
||||||
|
this.skillLightning = new ChainLightning();
|
||||||
|
this.leftHandWeapon.mesh.visible = false;
|
||||||
this.body.add(this.leftHandWeapon.mesh);
|
this.body.add(this.leftHandWeapon.mesh);
|
||||||
this.body.add(this.rightHandWeapon.mesh);
|
this.body.add(this.rightHandWeapon.mesh);
|
||||||
}
|
}
|
||||||
@@ -149,27 +178,74 @@ export class Player {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
switchHands() {
|
heal(amount: number) {
|
||||||
const temp = this.leftHandWeapon;
|
this.health = Math.min(this.health + amount, this.stats.maxHealth);
|
||||||
this.leftHandWeapon = this.rightHandWeapon as unknown as Staff;
|
|
||||||
this.rightHandWeapon = temp as unknown as Sword;
|
|
||||||
|
|
||||||
const leftPos = this.leftHandWeapon.mesh.position.clone();
|
|
||||||
const rightPos = this.rightHandWeapon.mesh.position.clone();
|
|
||||||
this.leftHandWeapon.mesh.position.copy(rightPos);
|
|
||||||
this.rightHandWeapon.mesh.position.copy(leftPos);
|
|
||||||
|
|
||||||
const leftRot = this.leftHandWeapon.mesh.rotation.clone();
|
|
||||||
const rightRot = this.rightHandWeapon.mesh.rotation.clone();
|
|
||||||
this.leftHandWeapon.mesh.rotation.copy(rightRot);
|
|
||||||
this.rightHandWeapon.mesh.rotation.copy(leftRot);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
heal(amount: number) {
|
damage(amount: number) {
|
||||||
this.health = Math.min(this.health + amount, this.MAX_HEALTH);
|
if (this.shieldHp > 0) {
|
||||||
|
const absorbed = Math.min(this.shieldHp, amount);
|
||||||
|
this.shieldHp -= absorbed;
|
||||||
|
amount -= absorbed;
|
||||||
|
this.shieldTimer = this.stats.shieldRechargeDelay;
|
||||||
|
}
|
||||||
|
this.health -= amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePassives(dt: number) {
|
||||||
|
if (this.stats.regen > 0) {
|
||||||
|
this.heal(this.stats.regen * dt);
|
||||||
|
}
|
||||||
|
if (this.stats.shield > 0) {
|
||||||
|
if (this.shieldHp < this.stats.shield) {
|
||||||
|
this.shieldTimer -= dt;
|
||||||
|
if (this.shieldTimer <= 0) {
|
||||||
|
this.shieldHp = this.stats.shield;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.shieldTimer = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
applySkillSystem(skills: SkillSystem) {
|
||||||
|
const oldMax = this.stats.maxHealth;
|
||||||
|
const l = (id: string) => skills.getLevel(id);
|
||||||
|
|
||||||
|
this.stats = {
|
||||||
|
maxHealth: 100 + 25 * l('maxHp'),
|
||||||
|
regen: 1.5 * l('regen'),
|
||||||
|
lifesteal: 0.05 * l('lifesteal'),
|
||||||
|
shield: 25 * l('shield'),
|
||||||
|
shieldRechargeDelay: 8,
|
||||||
|
moveSpeed: 7.5 + l('speed'),
|
||||||
|
swordDamage: 50 + 25 * l('swordDamage'),
|
||||||
|
swordRange: 2.2 + 0.6 * l('swordRange'),
|
||||||
|
thornDamage: 15 * l('thorns'),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.stats.maxHealth > oldMax) {
|
||||||
|
this.health = this.stats.maxHealth;
|
||||||
|
} else {
|
||||||
|
this.health = Math.min(this.health, this.stats.maxHealth);
|
||||||
|
}
|
||||||
|
if (this.stats.shield > 0 && this.shieldHp <= 0) {
|
||||||
|
this.shieldHp = this.stats.shield;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
die() {
|
die() {
|
||||||
this.playAnimation('die', false);
|
this.playAnimation('die', false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this.health = 100;
|
||||||
|
this.shieldHp = 0;
|
||||||
|
this.shieldTimer = 0;
|
||||||
|
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,212 @@
|
|||||||
|
export type SkillType = 'active' | 'passive';
|
||||||
|
|
||||||
|
export interface SkillDef {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: SkillType;
|
||||||
|
maxLevel: number;
|
||||||
|
describe(level: number): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillOffer {
|
||||||
|
id: string;
|
||||||
|
nextLevel: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SKILLS: SkillDef[] = [
|
||||||
|
{
|
||||||
|
id: 'fireball',
|
||||||
|
name: 'Feuerball',
|
||||||
|
type: 'active',
|
||||||
|
maxLevel: 5,
|
||||||
|
describe: (l) => l === 1
|
||||||
|
? 'Schießt einen Feuerball (100 DMG, 5s CD)'
|
||||||
|
: `${fireballDamage(l)} DMG · ${fireballCooldown(l)}s CD${fireballRadius(l) > 8 ? ' · größere Explosion' : ''}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'teleport',
|
||||||
|
name: 'Teleport',
|
||||||
|
type: 'active',
|
||||||
|
maxLevel: 3,
|
||||||
|
describe: (l) => `Teleport zum Zielpunkt (${teleportCooldown(l)}s CD)`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'chainlightning',
|
||||||
|
name: 'Kettenblitz',
|
||||||
|
type: 'active',
|
||||||
|
maxLevel: 5,
|
||||||
|
describe: (l) => `Blitz springt auf ${lightningJumps(l)} Gegner (${lightningDamage(l)} DMG, ${lightningCooldown(l)}s CD)`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'swordRange',
|
||||||
|
name: 'Reichweite',
|
||||||
|
type: 'passive',
|
||||||
|
maxLevel: 5,
|
||||||
|
describe: (l) => `Schwert-Reichweite auf ${swordRange(l).toFixed(1)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'swordDamage',
|
||||||
|
name: 'Schärfe',
|
||||||
|
type: 'passive',
|
||||||
|
maxLevel: 5,
|
||||||
|
describe: (l) => `Schwert-Schaden ${swordDamage(l)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'regen',
|
||||||
|
name: 'Regeneration',
|
||||||
|
type: 'passive',
|
||||||
|
maxLevel: 5,
|
||||||
|
describe: (l) => `Heilt ${(1.5 * l).toFixed(1)} HP/s`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lifesteal',
|
||||||
|
name: 'HP-Absorption',
|
||||||
|
type: 'passive',
|
||||||
|
maxLevel: 5,
|
||||||
|
describe: (l) => `${5 * l}% des Schadens heilt dich`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'shield',
|
||||||
|
name: 'Schild',
|
||||||
|
type: 'passive',
|
||||||
|
maxLevel: 5,
|
||||||
|
describe: (l) => `Absorbiert ${25 * l} Schaden, lädt nach 8s neu auf`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'maxHp',
|
||||||
|
name: 'Vitalität',
|
||||||
|
type: 'passive',
|
||||||
|
maxLevel: 5,
|
||||||
|
describe: (l) => `+25 max. HP (${100 + 25 * l}), heilt voll`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'thorns',
|
||||||
|
name: 'Dornen',
|
||||||
|
type: 'passive',
|
||||||
|
maxLevel: 3,
|
||||||
|
describe: (l) => `Gegner erleiden ${15 * l} DPS bei Kontakt`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'speed',
|
||||||
|
name: 'Flinke Füße',
|
||||||
|
type: 'passive',
|
||||||
|
maxLevel: 3,
|
||||||
|
describe: (l) => `Bewegungsgeschwindigkeit ${(7.5 + l).toFixed(1)}`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getSkill(id: string): SkillDef {
|
||||||
|
return SKILLS.find((s) => s.id === id)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fireballDamage(level: number): number {
|
||||||
|
return [100, 150, 200, 250, 300][Math.min(level, 5) - 1] ?? 100;
|
||||||
|
}
|
||||||
|
export function fireballCooldown(level: number): number {
|
||||||
|
return [5, 4.5, 4, 3.5, 3][Math.min(level, 5) - 1] ?? 5;
|
||||||
|
}
|
||||||
|
export function fireballRadius(level: number): number {
|
||||||
|
return [8, 8, 10, 10, 12][Math.min(level, 5) - 1] ?? 8;
|
||||||
|
}
|
||||||
|
export function teleportCooldown(level: number): number {
|
||||||
|
return [50, 35, 20][Math.min(level, 3) - 1] ?? 50;
|
||||||
|
}
|
||||||
|
export function lightningDamage(level: number): number {
|
||||||
|
return [40, 60, 80, 100, 120][Math.min(level, 5) - 1] ?? 40;
|
||||||
|
}
|
||||||
|
export function lightningJumps(level: number): number {
|
||||||
|
return [2, 3, 4, 5, 6][Math.min(level, 5) - 1] ?? 2;
|
||||||
|
}
|
||||||
|
export function lightningCooldown(level: number): number {
|
||||||
|
return [8, 7, 6, 5, 4][Math.min(level, 5) - 1] ?? 8;
|
||||||
|
}
|
||||||
|
export function swordRange(level: number): number {
|
||||||
|
return 2.2 + 0.6 * level;
|
||||||
|
}
|
||||||
|
export function swordDamage(level: number): number {
|
||||||
|
return 50 + 25 * level;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mulberry32(seed: number): () => number {
|
||||||
|
let a = seed >>> 0;
|
||||||
|
return function () {
|
||||||
|
a |= 0;
|
||||||
|
a = (a + 0x6D2B79F5) | 0;
|
||||||
|
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||||
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SkillSystem {
|
||||||
|
seed: number;
|
||||||
|
level = 0;
|
||||||
|
xp = 0;
|
||||||
|
private rng: () => number;
|
||||||
|
levels: Record<string, number> = {};
|
||||||
|
|
||||||
|
constructor(seed: number) {
|
||||||
|
this.seed = seed >>> 0;
|
||||||
|
this.rng = mulberry32(this.seed);
|
||||||
|
}
|
||||||
|
|
||||||
|
getLevel(id: string): number {
|
||||||
|
return this.levels[id] ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
xpNeeded(): number {
|
||||||
|
return 20 + this.level * 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
xpProgress(): number {
|
||||||
|
return this.xp / this.xpNeeded();
|
||||||
|
}
|
||||||
|
|
||||||
|
addXp(amount: number): boolean {
|
||||||
|
this.xp += amount;
|
||||||
|
if (!this.hasAnyUpgradeLeft()) return false;
|
||||||
|
if (this.xp >= this.xpNeeded()) {
|
||||||
|
this.xp -= this.xpNeeded();
|
||||||
|
this.level++;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasPendingLevelUp(): boolean {
|
||||||
|
return this.hasAnyUpgradeLeft() && this.xp >= this.xpNeeded();
|
||||||
|
}
|
||||||
|
|
||||||
|
hasAnyUpgradeLeft(): boolean {
|
||||||
|
return SKILLS.some((s) => (this.levels[s.id] ?? 0) < s.maxLevel);
|
||||||
|
}
|
||||||
|
|
||||||
|
apply(skillId: string) {
|
||||||
|
this.levels[skillId] = (this.levels[skillId] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
rollOffers(): SkillOffer[] {
|
||||||
|
const newPool = SKILLS.filter((s) => !this.levels[s.id]);
|
||||||
|
const upgradePool = SKILLS.filter((s) => {
|
||||||
|
const l = this.levels[s.id] ?? 0;
|
||||||
|
return l > 0 && l < s.maxLevel;
|
||||||
|
});
|
||||||
|
|
||||||
|
const ownedCount = Object.keys(this.levels).length;
|
||||||
|
const offers: SkillOffer[] = [];
|
||||||
|
while (offers.length < 3 && newPool.length + upgradePool.length > 0) {
|
||||||
|
let pool: SkillDef[];
|
||||||
|
if (newPool.length && upgradePool.length) {
|
||||||
|
const newBias = ownedCount < 4 ? 0.65 : 0.25;
|
||||||
|
pool = this.rng() < newBias ? newPool : upgradePool;
|
||||||
|
} else {
|
||||||
|
pool = newPool.length ? newPool : upgradePool;
|
||||||
|
}
|
||||||
|
const idx = Math.floor(this.rng() * pool.length);
|
||||||
|
const skill = pool[idx];
|
||||||
|
pool.splice(idx, 1);
|
||||||
|
offers.push({ id: skill.id, nextLevel: (this.levels[skill.id] ?? 0) + 1 });
|
||||||
|
}
|
||||||
|
return offers;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import type { Game } from './Game';
|
||||||
|
import { Enemy } from './Enemy';
|
||||||
|
import { playSound } from './SoundManager';
|
||||||
|
|
||||||
|
export class Slime extends Enemy {
|
||||||
|
private model: THREE.Group;
|
||||||
|
private bodyMesh: THREE.Mesh;
|
||||||
|
private time = Math.random() * Math.PI * 2;
|
||||||
|
private size: number;
|
||||||
|
|
||||||
|
constructor(size = 0) {
|
||||||
|
super();
|
||||||
|
this.size = size;
|
||||||
|
|
||||||
|
this.model = new THREE.Group();
|
||||||
|
this.fadeOutModel = this.model;
|
||||||
|
this.body.add(this.model);
|
||||||
|
|
||||||
|
const color = size === 0 ? 0x33cc88 : 0x66eebb;
|
||||||
|
const mat = new THREE.MeshToonMaterial({ color });
|
||||||
|
this.bodyMesh = new THREE.Mesh(new THREE.SphereGeometry(0.6, 20, 16), mat);
|
||||||
|
this.bodyMesh.position.y = 0.55;
|
||||||
|
this.bodyMesh.castShadow = true;
|
||||||
|
this.registerFadeMesh(this.bodyMesh);
|
||||||
|
this.model.add(this.bodyMesh);
|
||||||
|
|
||||||
|
// Cute eyes (face toward the player: -Z)
|
||||||
|
const eyeMat = new THREE.MeshToonMaterial({ color: 0x111122 });
|
||||||
|
for (const side of [-1, 1]) {
|
||||||
|
const eye = new THREE.Mesh(new THREE.SphereGeometry(0.09, 10, 8), eyeMat);
|
||||||
|
eye.position.set(side * 0.18, 0.72, -0.42);
|
||||||
|
this.registerFadeMesh(eye);
|
||||||
|
this.model.add(eye);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (size === 0) {
|
||||||
|
this.health = 40;
|
||||||
|
this.speed = 3.2;
|
||||||
|
this.xp = 12;
|
||||||
|
this.contactDps = 25;
|
||||||
|
} else {
|
||||||
|
this.health = 10;
|
||||||
|
this.speed = 4;
|
||||||
|
this.xp = 3;
|
||||||
|
this.contactDps = 12;
|
||||||
|
this.contactRadius = 1.5;
|
||||||
|
this.model.scale.setScalar(0.55);
|
||||||
|
this.grace = 0.3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
playAnim() {
|
||||||
|
// Procedural visuals only
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAnimation(dt: number) {
|
||||||
|
if (this.grace > 0) this.grace -= dt;
|
||||||
|
this.updateFadeDeath(dt, { duration: 0.7, shrink: 0.9 });
|
||||||
|
}
|
||||||
|
|
||||||
|
update(dt: number, game: Game) {
|
||||||
|
this.time += dt;
|
||||||
|
|
||||||
|
// Squishy pulse
|
||||||
|
const pulse = 1 + Math.sin(this.time * 5) * 0.08;
|
||||||
|
this.model.scale.y = (this.size === 0 ? 1 : 0.55) * pulse;
|
||||||
|
const squash = 1 + (1 - pulse) * 0.5;
|
||||||
|
this.model.scale.x = (this.size === 0 ? 1 : 0.55) * squash;
|
||||||
|
this.model.scale.z = this.model.scale.x;
|
||||||
|
|
||||||
|
// Hop-wiggle toward the player
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onDeath(game: Game) {
|
||||||
|
this.beginFadeDeath();
|
||||||
|
if (this.size === 0) {
|
||||||
|
playSound('spawn', 0.5);
|
||||||
|
for (let i = 0; i < 2; i++) {
|
||||||
|
const child = new Slime(1);
|
||||||
|
game.spawnEnemyInstance(
|
||||||
|
child,
|
||||||
|
this.body.position.x + (Math.random() - 0.5) * 1.2,
|
||||||
|
this.body.position.z + (Math.random() - 0.5) * 1.2,
|
||||||
|
0.1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(_game: Game) {
|
||||||
|
this.playAnim('die', false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { Player } from './Player';
|
|||||||
import { Fireball } from './Fireball';
|
import { Fireball } from './Fireball';
|
||||||
import { playSound } from './SoundManager';
|
import { playSound } from './SoundManager';
|
||||||
import { loadGLB } from './MeshLoader';
|
import { loadGLB } from './MeshLoader';
|
||||||
|
import { fireballDamage, fireballCooldown, fireballRadius, teleportCooldown } from './Skills';
|
||||||
|
|
||||||
export class Staff extends Weapon {
|
export class Staff extends Weapon {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -52,7 +53,11 @@ export class Staff extends Weapon {
|
|||||||
}
|
}
|
||||||
|
|
||||||
skill1(groundPoint: THREE.Vector3, player: Player): void {
|
skill1(groundPoint: THREE.Vector3, player: Player): void {
|
||||||
|
const level = player.game.skillSystem.getLevel('fireball');
|
||||||
|
if (level <= 0) return;
|
||||||
if (this.cooldown1 > 0) return;
|
if (this.cooldown1 > 0) return;
|
||||||
|
|
||||||
|
this.COOLDOWN1_TIME = fireballCooldown(level);
|
||||||
this.cooldown1 = this.COOLDOWN1_TIME;
|
this.cooldown1 = this.COOLDOWN1_TIME;
|
||||||
|
|
||||||
// Spawn at staff height, fly toward the cursor point
|
// Spawn at staff height, fly toward the cursor point
|
||||||
@@ -66,13 +71,19 @@ export class Staff extends Weapon {
|
|||||||
dir.setY(0);
|
dir.setY(0);
|
||||||
|
|
||||||
const fb = new Fireball(spawnPos, dir);
|
const fb = new Fireball(spawnPos, dir);
|
||||||
|
fb.damage = fireballDamage(level);
|
||||||
|
fb.explosionRadius = fireballRadius(level);
|
||||||
player.game.scene.add(fb.body);
|
player.game.scene.add(fb.body);
|
||||||
player.game.fireballs.push(fb);
|
player.game.fireballs.push(fb);
|
||||||
playSound('fireball', 0.5);
|
playSound('fireball', 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
skill2(groundPoint: THREE.Vector3, player: Player): void {
|
skill2(groundPoint: THREE.Vector3, player: Player): void {
|
||||||
|
const level = player.game.skillSystem.getLevel('teleport');
|
||||||
|
if (level <= 0) return;
|
||||||
if (this.cooldown2 > 0) return;
|
if (this.cooldown2 > 0) return;
|
||||||
|
|
||||||
|
this.COOLDOWN2_TIME = teleportCooldown(level);
|
||||||
this.cooldown2 = this.COOLDOWN2_TIME;
|
this.cooldown2 = this.COOLDOWN2_TIME;
|
||||||
|
|
||||||
const newPos = groundPoint.clone();
|
const newPos = groundPoint.clone();
|
||||||
|
|||||||
+24
-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,20 +98,33 @@ export class Sword extends Weapon {
|
|||||||
|
|
||||||
const { game } = player;
|
const { game } = player;
|
||||||
|
|
||||||
|
const range = player.stats.swordRange;
|
||||||
|
const damage = player.stats.swordDamage;
|
||||||
|
|
||||||
|
// 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, range);
|
||||||
|
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 (!enemy.canBeSlashHit()) continue;
|
||||||
if (dist < 3) {
|
if (isInSlashArc(player.body.position, aim, enemy.body.position, range)) {
|
||||||
const toEnemy = new THREE.Vector3()
|
enemy.takeDamage(damage, game, true, player.body.position);
|
||||||
.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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Destroy enemy projectiles caught in the slash
|
||||||
|
game.destroyProjectilesInArc(player.body.position, aim, range);
|
||||||
}
|
}
|
||||||
|
|
||||||
skill2(): void {
|
skill2(): void {
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
const DURATION = 0.3;
|
||||||
|
const SPARK_COUNT = 20;
|
||||||
|
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,
|
||||||
|
radius: number
|
||||||
|
): 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 > 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,
|
||||||
|
radius: number
|
||||||
|
) {
|
||||||
|
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, 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, 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 sparkRadius = radius * 0.55 + Math.random() * radius * 0.45;
|
||||||
|
this.sparkPositions[i * 3] = Math.sin(angle) * sparkRadius;
|
||||||
|
this.sparkPositions[i * 3 + 1] = 0.2 + Math.random() * 0.3;
|
||||||
|
this.sparkPositions[i * 3 + 2] = Math.cos(angle) * sparkRadius;
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import type { Game } from './Game';
|
||||||
|
import { Enemy } from './Enemy';
|
||||||
|
|
||||||
|
const SHOOT_INTERVAL = 3;
|
||||||
|
const SHOOT_RANGE = 20;
|
||||||
|
|
||||||
|
export class Turret extends Enemy {
|
||||||
|
private model: THREE.Group;
|
||||||
|
private cap: THREE.Mesh;
|
||||||
|
private shootTimer = 1 + Math.random();
|
||||||
|
private swayTime = Math.random() * Math.PI * 2;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.health = 80;
|
||||||
|
this.speed = 0;
|
||||||
|
this.xp = 25;
|
||||||
|
this.contactDps = 0;
|
||||||
|
|
||||||
|
this.model = new THREE.Group();
|
||||||
|
this.fadeOutModel = this.model;
|
||||||
|
this.body.add(this.model);
|
||||||
|
|
||||||
|
const stalkMat = new THREE.MeshToonMaterial({ color: 0xe8dcc0 });
|
||||||
|
const capMat = new THREE.MeshToonMaterial({ color: 0xcc3344 });
|
||||||
|
const spotMat = new THREE.MeshToonMaterial({ color: 0xfff0e0 });
|
||||||
|
|
||||||
|
// Stalk
|
||||||
|
const stalk = new THREE.Mesh(new THREE.CylinderGeometry(0.16, 0.28, 0.8, 10), stalkMat);
|
||||||
|
stalk.position.y = 0.4;
|
||||||
|
stalk.castShadow = true;
|
||||||
|
this.registerFadeMesh(stalk);
|
||||||
|
this.model.add(stalk);
|
||||||
|
|
||||||
|
// Cap
|
||||||
|
this.cap = new THREE.Mesh(new THREE.SphereGeometry(0.5, 18, 14), capMat);
|
||||||
|
this.cap.scale.set(1, 0.65, 1);
|
||||||
|
this.cap.position.y = 0.9;
|
||||||
|
this.cap.castShadow = true;
|
||||||
|
this.registerFadeMesh(this.cap);
|
||||||
|
this.model.add(this.cap);
|
||||||
|
|
||||||
|
// Spots
|
||||||
|
for (const [dx, dz] of [[0, 0], [0.28, 0.12], [-0.26, 0.18], [0.05, -0.3]] as const) {
|
||||||
|
const spot = new THREE.Mesh(new THREE.SphereGeometry(0.09, 10, 8), spotMat);
|
||||||
|
spot.scale.set(1, 0.45, 1);
|
||||||
|
spot.position.set(dx, 1.16, dz);
|
||||||
|
this.registerFadeMesh(spot);
|
||||||
|
this.model.add(spot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
playAnim() {
|
||||||
|
// Procedural visuals only
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAnimation(dt: number) {
|
||||||
|
this.updateFadeDeath(dt, { duration: 0.9, sink: 0.3 });
|
||||||
|
}
|
||||||
|
|
||||||
|
update(dt: number, game: Game) {
|
||||||
|
this.swayTime += dt;
|
||||||
|
|
||||||
|
// Slight sway
|
||||||
|
this.model.rotation.z = Math.sin(this.swayTime * 1.5) * 0.06;
|
||||||
|
this.cap.rotation.y = this.swayTime * 0.4;
|
||||||
|
|
||||||
|
const playerPos = game.player.body.position;
|
||||||
|
const toPlayer = new THREE.Vector3().subVectors(playerPos, this.body.position);
|
||||||
|
toPlayer.y = 0;
|
||||||
|
const dist = toPlayer.length();
|
||||||
|
|
||||||
|
if (dist > 0.1) {
|
||||||
|
this.body.lookAt(
|
||||||
|
this.body.position.x + toPlayer.x,
|
||||||
|
this.body.position.y,
|
||||||
|
this.body.position.z + toPlayer.z
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.shootTimer -= dt;
|
||||||
|
if (dist < SHOOT_RANGE && this.shootTimer <= 0 && game.player.health > 0) {
|
||||||
|
this.shootTimer = SHOOT_INTERVAL + Math.random() * 0.5;
|
||||||
|
const from = this.body.position.clone();
|
||||||
|
from.y += 1.0;
|
||||||
|
const target = playerPos.clone();
|
||||||
|
target.y += 0.6;
|
||||||
|
game.spawnEnemyProjectile(from, target, 15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onDeath(_game: Game) {
|
||||||
|
this.beginFadeDeath();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,5 +9,8 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
host: true,
|
host: true,
|
||||||
port: 3000,
|
port: 3000,
|
||||||
|
watch: {
|
||||||
|
ignored: ['**/dist/**'],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user