Add skill system, level-ups, and new enemy types
This commit is contained in:
@@ -5,9 +5,13 @@ Browser-Port des 2012er jMonkeyEngine-Spiels "Wackelpeter" (Blobtest).
|
||||
## Übersicht
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
Das Original lag nur kompiliert vor (Blobtest.exe + JME3-Libs). Der Source
|
||||
@@ -45,8 +49,11 @@ npm run dev
|
||||
|
||||
- Three.js + TypeScript + Vite
|
||||
- Steuerung: WASD bewegen, Space springen, Maus zielen,
|
||||
Linksklick Feuerball, Rechtsklick Schwert, M3 Hände tauschen,
|
||||
E Teleport, H Hitboxen (Debug)
|
||||
Linksklick Schwert, Rechtsklick Feuerball (per Skill),
|
||||
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
|
||||
|
||||
|
||||
+123
-6
@@ -7,7 +7,10 @@
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
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 {
|
||||
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
|
||||
pointer-events: none; font-family: monospace; z-index: 10;
|
||||
@@ -50,6 +53,7 @@
|
||||
#fireball-cooldown { bottom: 72px; }
|
||||
#sword-cooldown { bottom: 48px; }
|
||||
#teleport-cooldown { bottom: 24px; }
|
||||
#lightning-cooldown { bottom: 96px; }
|
||||
#wave-bar-container {
|
||||
position: absolute; top: 16px; right: 16px; width: 250px; height: 16px;
|
||||
}
|
||||
@@ -102,22 +106,107 @@
|
||||
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 {
|
||||
position: absolute; bottom: 16px; left: 50%; transform: translateX(-50%);
|
||||
color: rgba(255,255,255,0.5); font-size: 11px; font-family: monospace;
|
||||
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>
|
||||
</head>
|
||||
<body>
|
||||
<div id="ui-overlay">
|
||||
<div id="health-bar-bg"></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="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-fill"></div>
|
||||
<div class="cooldown-label">Fireball</div>
|
||||
@@ -127,11 +216,16 @@
|
||||
<div class="cooldown-fill"></div>
|
||||
<div class="cooldown-label">Sword</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-fill"></div>
|
||||
<div class="cooldown-label">Teleport</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-bg"></div>
|
||||
@@ -146,18 +240,41 @@
|
||||
<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: Feuerball · Rechtsklick: Schwert · M3: Hände tauschen · E: Teleport
|
||||
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">
|
||||
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>
|
||||
<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();
|
||||
}
|
||||
}
|
||||
+65
-5
@@ -9,10 +9,19 @@ export abstract class Enemy {
|
||||
knockback = 0;
|
||||
health = 100;
|
||||
speed = 6;
|
||||
xp = 10;
|
||||
contactDps = 25;
|
||||
contactRadius = 2;
|
||||
guaranteedDrop = false;
|
||||
grace = 0;
|
||||
mixer: THREE.AnimationMixer | null = null;
|
||||
protected clips: Map<string, THREE.AnimationAction> = new Map();
|
||||
protected currentAnim = '';
|
||||
protected stopWalkSound: (() => void) | null = null;
|
||||
protected fadeOutModel: THREE.Group | null = null;
|
||||
protected fadeMaterials: THREE.Material[] = [];
|
||||
private dying = false;
|
||||
private dieTime = 0;
|
||||
|
||||
constructor() {
|
||||
this.body = new THREE.Group();
|
||||
@@ -22,16 +31,67 @@ export abstract class Enemy {
|
||||
abstract updateAnimation(dt: number): void;
|
||||
abstract update(dt: number, game: Game): void;
|
||||
|
||||
protected onDeath() {
|
||||
// Subclasses override for model-specific death visuals
|
||||
canBeSlashHit(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
takeDamage(damage: number, game: Game) {
|
||||
if (this.dead) return;
|
||||
protected onDeath(game: Game) {
|
||||
// Subclasses override for model-specific death behavior
|
||||
}
|
||||
|
||||
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 {
|
||||
this.body.scale.set(scale, scale, scale);
|
||||
}
|
||||
const sink = opts?.sink ?? 0;
|
||||
if (sink > 0) {
|
||||
this.body.position.y = Math.max(-1, this.body.position.y - sink * dt);
|
||||
}
|
||||
const opacity = 1 - t;
|
||||
for (const mat of this.fadeMaterials) {
|
||||
(mat as THREE.Material & { opacity: number }).opacity = opacity;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
takeDamage(
|
||||
damage: number,
|
||||
game: Game,
|
||||
withKnockback = true,
|
||||
sourcePos?: THREE.Vector3
|
||||
) {
|
||||
if (this.dead || this.grace > 0) return;
|
||||
this.health -= damage;
|
||||
if (withKnockback) {
|
||||
this.knockback = 0.5;
|
||||
}
|
||||
|
||||
playSound('damage', 0.7);
|
||||
game.onDamageDealt(damage);
|
||||
|
||||
if (this.health <= 0) {
|
||||
this.die(game);
|
||||
@@ -41,7 +101,7 @@ export abstract class Enemy {
|
||||
private die(game: Game) {
|
||||
if (this.dead) return;
|
||||
this.dead = true;
|
||||
this.onDeath();
|
||||
this.onDeath(game);
|
||||
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;
|
||||
timer = 0;
|
||||
damageDone = false;
|
||||
damage = 100;
|
||||
explosionRadius = 8;
|
||||
private trailParticles: THREE.Points;
|
||||
private trailVelocities: 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();
|
||||
}
|
||||
}
|
||||
+352
-24
@@ -3,17 +3,27 @@ import { Player } from './Player';
|
||||
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 { Staff } from './Staff';
|
||||
import { Fireball } from './Fireball';
|
||||
import { Cross, initCrossModel } from './Cross';
|
||||
import { Arena } from './Arena';
|
||||
import { SlashEffect } from './SwordTrail';
|
||||
import { SlashEffect, isInSlashArc } from './SwordTrail';
|
||||
import { SkillSystem, getSkill, type SkillOffer } from './Skills';
|
||||
import { initSounds, playSound, resumeContext } from './SoundManager';
|
||||
|
||||
const SPAWN_TIME = 10;
|
||||
|
||||
type GameState = 'menu' | 'playing' | 'gameover';
|
||||
type GameState = 'menu' | 'playing' | 'gameover' | 'levelup';
|
||||
|
||||
export class Game {
|
||||
scene: THREE.Scene;
|
||||
@@ -26,8 +36,15 @@ export class Game {
|
||||
fireballs: Fireball[] = [];
|
||||
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();
|
||||
mouseButtons: Set<number> = new Set();
|
||||
@@ -53,14 +70,30 @@ export class Game {
|
||||
private uiWaveLabel!: HTMLElement;
|
||||
private uiKillsLabel!: HTMLElement;
|
||||
private uiFireballCD!: HTMLElement;
|
||||
private uiFireballContainer!: HTMLElement;
|
||||
private uiSwordCD!: HTMLElement;
|
||||
private uiTeleportCD!: HTMLElement;
|
||||
private uiTeleportContainer!: HTMLElement;
|
||||
private uiLightningCD!: HTMLElement;
|
||||
private uiLightningContainer!: HTMLElement;
|
||||
private uiWaveBarFill!: HTMLElement;
|
||||
private uiWaveBarLabel!: 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;
|
||||
|
||||
constructor() {
|
||||
@@ -120,23 +153,58 @@ export class Game {
|
||||
this.uiWaveLabel = document.getElementById('wave-label')!;
|
||||
this.uiKillsLabel = document.getElementById('kills-label')!;
|
||||
this.uiFireballCD = document.querySelector('#fireball-cooldown .cooldown-fill')!;
|
||||
this.uiFireballContainer = document.getElementById('fireball-cooldown')!;
|
||||
this.uiSwordCD = document.querySelector('#sword-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.uiWaveBarLabel = document.getElementById('wave-bar-label')!;
|
||||
this.uiGameOver = document.getElementById('game-over')!;
|
||||
this.uiGameOverPanel = document.getElementById('game-over-panel')!;
|
||||
this.uiGameOverStats = document.getElementById('game-over-stats')!;
|
||||
this.uiMainMenu = document.getElementById('main-menu')!;
|
||||
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;
|
||||
canvas.addEventListener('mouseenter', () => this.mouseOnCanvas = true);
|
||||
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() {
|
||||
@@ -149,9 +217,25 @@ export class Game {
|
||||
if (e.code === 'KeyH') {
|
||||
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') this.restart();
|
||||
else if (
|
||||
this.state === 'gameover' &&
|
||||
performance.now() - this.gameOverAt > 1500
|
||||
) {
|
||||
this.restart();
|
||||
}
|
||||
}
|
||||
});
|
||||
window.addEventListener('keyup', (e) => {
|
||||
@@ -180,11 +264,9 @@ export class Game {
|
||||
private handleMouseRelease(button: number) {
|
||||
if (this.state !== 'playing') return;
|
||||
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);
|
||||
} else if (button === 2) {
|
||||
this.player.leftHandWeapon.skill1(this.groundPoint, this.player);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,15 +390,31 @@ export class Game {
|
||||
}
|
||||
|
||||
startGame() {
|
||||
if (this.state === 'playing') return;
|
||||
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() {
|
||||
@@ -325,20 +423,27 @@ export class Game {
|
||||
|
||||
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}`;
|
||||
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';
|
||||
@@ -361,6 +466,9 @@ export class Game {
|
||||
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();
|
||||
@@ -383,32 +491,185 @@ export class Game {
|
||||
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();
|
||||
}
|
||||
|
||||
private spawnEnemy() {
|
||||
const ghostChance = this.spawns >= 3
|
||||
? Math.min(0.15 + (this.spawns - 3) * 0.08, 0.6)
|
||||
: 0;
|
||||
const isGhost = Math.random() < ghostChance;
|
||||
const enemy = isGhost ? new Ghost() : new Spider();
|
||||
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() {
|
||||
const x = (Math.random() - 0.5) * 80;
|
||||
const z = (Math.random() - 0.5) * 80;
|
||||
enemy.body.position.set(x, isGhost ? 1.0 : 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.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) {
|
||||
if (Math.random() > 0.9) {
|
||||
const cross = new Cross(position.clone());
|
||||
@@ -452,7 +713,7 @@ export class Game {
|
||||
if (this.keys.has('KeyA') || this.keys.has('ArrowLeft')) moveDir.add(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;
|
||||
if (moveDir.length() > 0) {
|
||||
moveDir.normalize();
|
||||
@@ -521,8 +782,8 @@ export class Game {
|
||||
for (const enemy of this.enemies) {
|
||||
if (enemy.dead) continue;
|
||||
const dist = enemy.body.position.distanceTo(fb.body.position);
|
||||
if (dist < 8) {
|
||||
enemy.takeDamage(100, this);
|
||||
if (dist < fb.explosionRadius) {
|
||||
enemy.takeDamage(fb.damage, this, true, fb.body.position);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -553,15 +814,37 @@ export class Game {
|
||||
}
|
||||
|
||||
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.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.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 staff = this.player.leftHandWeapon as Staff;
|
||||
const lightning = this.player.skillLightning;
|
||||
|
||||
const fbPct = Math.max(0, (staff.COOLDOWN1_TIME - staff.cooldown1) / staff.COOLDOWN1_TIME);
|
||||
this.uiFireballCD.style.width = `${fbPct * 100}%`;
|
||||
@@ -572,6 +855,9 @@ export class Game {
|
||||
const tpPct = Math.max(0, (staff.COOLDOWN2_TIME - staff.cooldown2) / staff.COOLDOWN2_TIME);
|
||||
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;
|
||||
this.uiWaveBarFill.style.width = `${wavePct * 100}%`;
|
||||
this.uiWaveBarLabel.textContent = `Next Wave: ${Math.ceil(SPAWN_TIME - this.spawnTimer)}s`;
|
||||
@@ -608,20 +894,44 @@ export class Game {
|
||||
this.spawnTimer += dt;
|
||||
if (this.spawnTimer >= SPAWN_TIME) {
|
||||
this.spawns++;
|
||||
if (this.spawns % 5 === 0) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// Check close encounters (enemy touching player)
|
||||
if (this.state === 'playing') {
|
||||
const thorns = this.player.stats.thornDamage;
|
||||
for (const enemy of this.enemies) {
|
||||
if (enemy.dead) continue;
|
||||
const dist = enemy.body.position.distanceTo(this.player.body.position);
|
||||
if (dist < 2) {
|
||||
this.player.health -= 25 * dt;
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -643,6 +953,7 @@ export class Game {
|
||||
// Update cooldowns
|
||||
this.player.rightHandWeapon.reduceCooldowns(dt);
|
||||
this.player.leftHandWeapon.reduceCooldowns(dt);
|
||||
this.player.skillLightning.reduceCooldowns(dt);
|
||||
|
||||
// Camera follows player
|
||||
this.camera.position.set(
|
||||
@@ -675,6 +986,23 @@ export class Game {
|
||||
removeEnemy(enemy: Enemy) {
|
||||
enemy.dead = true;
|
||||
this.killed++;
|
||||
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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+8
-7
@@ -24,13 +24,14 @@ export class Ghost extends Enemy {
|
||||
private bobTime = Math.random() * Math.PI * 2;
|
||||
private visState: VisState = 'visible';
|
||||
private visTimer = VISIBLE_TIME + Math.random();
|
||||
private dying = false;
|
||||
private dieTime = 0;
|
||||
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);
|
||||
@@ -132,9 +133,9 @@ export class Ghost extends Enemy {
|
||||
}
|
||||
|
||||
updateAnimation(dt: number) {
|
||||
if (!this.dying) return;
|
||||
this.dieTime += dt;
|
||||
const t = Math.min(this.dieTime / 0.9, 1);
|
||||
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);
|
||||
@@ -230,8 +231,8 @@ export class Ghost extends Enemy {
|
||||
for (const mesh of this.shadowCasters) mesh.castShadow = casts;
|
||||
}
|
||||
|
||||
protected onDeath() {
|
||||
this.dying = true;
|
||||
protected onDeath(_game: Game) {
|
||||
this.fadeDying = true;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
|
||||
+85
-18
@@ -1,19 +1,46 @@
|
||||
import * as THREE from 'three';
|
||||
import { Sword } from './Sword';
|
||||
import { Staff } from './Staff';
|
||||
import { ChainLightning } from './ChainLightning';
|
||||
import { loadGLB } from './MeshLoader';
|
||||
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 {
|
||||
body: THREE.Group;
|
||||
leftHandWeapon: Staff;
|
||||
rightHandWeapon: Sword;
|
||||
skillLightning: ChainLightning;
|
||||
velocity = new THREE.Vector3();
|
||||
health = 100;
|
||||
MAX_HEALTH = 100;
|
||||
game!: Game;
|
||||
jumpVelocity = 0;
|
||||
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;
|
||||
private clips: Map<string, THREE.AnimationAction> = new Map();
|
||||
private currentAnim = '';
|
||||
@@ -64,6 +91,8 @@ export class Player {
|
||||
// Weapons
|
||||
this.leftHandWeapon = new Staff();
|
||||
this.rightHandWeapon = new Sword();
|
||||
this.skillLightning = new ChainLightning();
|
||||
this.leftHandWeapon.mesh.visible = false;
|
||||
this.body.add(this.leftHandWeapon.mesh);
|
||||
this.body.add(this.rightHandWeapon.mesh);
|
||||
}
|
||||
@@ -149,24 +178,60 @@ export class Player {
|
||||
}
|
||||
}
|
||||
|
||||
switchHands() {
|
||||
const temp = this.leftHandWeapon;
|
||||
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) {
|
||||
this.health = Math.min(this.health + amount, this.stats.maxHealth);
|
||||
}
|
||||
|
||||
heal(amount: number) {
|
||||
this.health = Math.min(this.health + amount, this.MAX_HEALTH);
|
||||
damage(amount: number) {
|
||||
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() {
|
||||
@@ -174,7 +239,9 @@ export class Player {
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.health = this.MAX_HEALTH;
|
||||
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;
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -132,7 +132,7 @@ export class Spider extends Enemy {
|
||||
}
|
||||
}
|
||||
|
||||
protected onDeath() {
|
||||
protected onDeath(_game: Game) {
|
||||
this.playAnim('die', false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Player } from './Player';
|
||||
import { Fireball } from './Fireball';
|
||||
import { playSound } from './SoundManager';
|
||||
import { loadGLB } from './MeshLoader';
|
||||
import { fireballDamage, fireballCooldown, fireballRadius, teleportCooldown } from './Skills';
|
||||
|
||||
export class Staff extends Weapon {
|
||||
constructor() {
|
||||
@@ -52,7 +53,11 @@ export class Staff extends Weapon {
|
||||
}
|
||||
|
||||
skill1(groundPoint: THREE.Vector3, player: Player): void {
|
||||
const level = player.game.skillSystem.getLevel('fireball');
|
||||
if (level <= 0) return;
|
||||
if (this.cooldown1 > 0) return;
|
||||
|
||||
this.COOLDOWN1_TIME = fireballCooldown(level);
|
||||
this.cooldown1 = this.COOLDOWN1_TIME;
|
||||
|
||||
// Spawn at staff height, fly toward the cursor point
|
||||
@@ -66,13 +71,19 @@ export class Staff extends Weapon {
|
||||
dir.setY(0);
|
||||
|
||||
const fb = new Fireball(spawnPos, dir);
|
||||
fb.damage = fireballDamage(level);
|
||||
fb.explosionRadius = fireballRadius(level);
|
||||
player.game.scene.add(fb.body);
|
||||
player.game.fireballs.push(fb);
|
||||
playSound('fireball', 0.5);
|
||||
}
|
||||
|
||||
skill2(groundPoint: THREE.Vector3, player: Player): void {
|
||||
const level = player.game.skillSystem.getLevel('teleport');
|
||||
if (level <= 0) return;
|
||||
if (this.cooldown2 > 0) return;
|
||||
|
||||
this.COOLDOWN2_TIME = teleportCooldown(level);
|
||||
this.cooldown2 = this.COOLDOWN2_TIME;
|
||||
|
||||
const newPos = groundPoint.clone();
|
||||
|
||||
+10
-3
@@ -98,6 +98,9 @@ export class Sword extends Weapon {
|
||||
|
||||
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);
|
||||
@@ -109,15 +112,19 @@ export class Sword extends Weapon {
|
||||
aim.normalize();
|
||||
|
||||
// Slash trail feedback
|
||||
const slash = new SlashEffect(player.body.position, aim, game.scene);
|
||||
const slash = new SlashEffect(player.body.position, aim, game.scene, range);
|
||||
game.slashEffects.push(slash);
|
||||
|
||||
for (const enemy of game.enemies) {
|
||||
if (enemy.dead) continue;
|
||||
if (isInSlashArc(player.body.position, aim, enemy.body.position)) {
|
||||
enemy.takeDamage(50, game);
|
||||
if (!enemy.canBeSlashHit()) continue;
|
||||
if (isInSlashArc(player.body.position, aim, enemy.body.position, range)) {
|
||||
enemy.takeDamage(damage, game, true, player.body.position);
|
||||
}
|
||||
}
|
||||
|
||||
// Destroy enemy projectiles caught in the slash
|
||||
game.destroyProjectilesInArc(player.body.position, aim, range);
|
||||
}
|
||||
|
||||
skill2(): void {
|
||||
|
||||
+15
-12
@@ -2,11 +2,8 @@ import * as THREE from 'three';
|
||||
|
||||
const DURATION = 0.3;
|
||||
const SPARK_COUNT = 20;
|
||||
const SLASH_HALF_ANGLE = 1.22;
|
||||
|
||||
// Hit zone of the sword slash. The trail visual is generated from these
|
||||
// exact values so what the player sees is what can actually be hit.
|
||||
export const SLASH_RADIUS = 2.2;
|
||||
export const SLASH_HALF_ANGLE = 1.22;
|
||||
// Enemies are ~0.5-0.7 wide; allow their body to overlap the arc edge.
|
||||
const HIT_MARGIN = 0.6;
|
||||
|
||||
@@ -30,13 +27,14 @@ function arcGeometry(r0: number, r1: number): THREE.ShapeGeometry {
|
||||
export function isInSlashArc(
|
||||
playerPos: THREE.Vector3,
|
||||
forward: THREE.Vector3,
|
||||
targetPos: 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 > SLASH_RADIUS + HIT_MARGIN) return false;
|
||||
if (dist > radius + HIT_MARGIN) return false;
|
||||
if (dist < 0.01) return true;
|
||||
return fwd.dot(toTarget.normalize()) > Math.cos(SLASH_HALF_ANGLE);
|
||||
}
|
||||
@@ -54,7 +52,12 @@ export class SlashEffect {
|
||||
private t = 0;
|
||||
private done = false;
|
||||
|
||||
constructor(position: THREE.Vector3, forward: THREE.Vector3, scene: THREE.Scene) {
|
||||
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);
|
||||
@@ -72,7 +75,7 @@ export class SlashEffect {
|
||||
side: THREE.DoubleSide,
|
||||
});
|
||||
const outer = new THREE.Mesh(
|
||||
arcGeometry(0.5, SLASH_RADIUS),
|
||||
arcGeometry(0.5, radius),
|
||||
this.outerMat
|
||||
);
|
||||
this.group.add(outer);
|
||||
@@ -87,7 +90,7 @@ export class SlashEffect {
|
||||
side: THREE.DoubleSide,
|
||||
});
|
||||
const inner = new THREE.Mesh(
|
||||
arcGeometry(0.7, SLASH_RADIUS * 0.8),
|
||||
arcGeometry(0.7, radius * 0.8),
|
||||
this.innerMat
|
||||
);
|
||||
this.group.add(inner);
|
||||
@@ -99,10 +102,10 @@ export class SlashEffect {
|
||||
for (let i = 0; i < SPARK_COUNT; i++) {
|
||||
const angle = -SLASH_HALF_ANGLE
|
||||
+ (i / (SPARK_COUNT - 1)) * SLASH_HALF_ANGLE * 2;
|
||||
const radius = 1.2 + Math.random() * (SLASH_RADIUS - 1.2);
|
||||
this.sparkPositions[i * 3] = Math.sin(angle) * radius;
|
||||
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) * radius;
|
||||
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;
|
||||
|
||||
@@ -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: {
|
||||
host: true,
|
||||
port: 3000,
|
||||
watch: {
|
||||
ignored: ['**/dist/**'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user