Compare commits

...
3 Commits
Author SHA1 Message Date
nico 6f207ba1a0 Add skill system, level-ups, and new enemy types 2026-08-17 16:38:58 +02:00
nico 4751d428a7 Add main menu, ghost enemy, and sword slash trail 2026-08-17 13:03:19 +02:00
nico 0b19c3d26b Add AGENTS.md with git rules 2026-08-17 11:39:39 +02:00
24 changed files with 2951 additions and 203 deletions
+7
View File
@@ -0,0 +1,7 @@
# AGENTS.md
## Git-Regeln
- Git-Commits nur auf explizite Anforderung des Users. Niemals eigenständig committen.
- Vor einem Commit: `git status`, `git diff` und `git log --oneline -10` prüfen; nur beabsichtigte Dateien stagen, keine Secrets.
- Commit-Message kurz und im Stil des Repos (bisher englisch, imperative).
+10 -3
View File
@@ -5,9 +5,13 @@ Browser-Port des 2012er jMonkeyEngine-Spiels "Wackelpeter" (Blobtest).
## Übersicht ## Übersicht
3D Arena-Survival-Spiel: Der Blob "Wackelpeter" kämpft mit Schwert und 3D Arena-Survival-Spiel: Der Blob "Wackelpeter" kämpft mit Schwert und
Feuerball-Stab gegen Wellen von Spinnen. Bei 10% Kill-Chance droppt ein Feuerball-Stab gegen Wellen von Monstern. Bei 10% Kill-Chance droppt ein
heilendes Kreuz. Cel-Shading-Look, Wellen werden alle 10 Sekunden stärker. heilendes Kreuz. Cel-Shading-Look, Wellen werden alle 10 Sekunden stärker.
Monster: Spinnen (ab Welle 1), Geister (ab 3), springende Frösche (ab 4),
Schleime (ab 5, teilen sich), Fledermäuse (ab 5), Pilz-Türme (ab 6),
Krabben (ab 7), Bienenschwärme (ab 7) und alle 5 Wellen ein Boss-Golem.
## Restaurierung ## Restaurierung
Das Original lag nur kompiliert vor (Blobtest.exe + JME3-Libs). Der Source Das Original lag nur kompiliert vor (Blobtest.exe + JME3-Libs). Der Source
@@ -45,8 +49,11 @@ npm run dev
- Three.js + TypeScript + Vite - Three.js + TypeScript + Vite
- Steuerung: WASD bewegen, Space springen, Maus zielen, - Steuerung: WASD bewegen, Space springen, Maus zielen,
Linksklick Feuerball, Rechtsklick Schwert, M3 Hände tauschen, Linksklick Schwert, Rechtsklick Feuerball (per Skill),
E Teleport, H Hitboxen (Debug) Q Kettenblitz (per Skill), E Teleport (per Skill), H Hitboxen (Debug)
- Roguelike-Skillsystem: Kills geben XP, bei Level-Up pausiert das Spiel
und bietet 3 Skill-Karten (neue Skills oder Upgrades). Seed (Startmenü)
steuert die Angebote, "Nochmal spielen" behält den Seed bei.
## Verzeichnisse ## Verzeichnisse
+170 -5
View File
@@ -7,7 +7,10 @@
<style> <style>
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #000; } html, body { width: 100%; height: 100%; overflow: hidden; background: #000; }
canvas { display: block; } canvas {
display: block;
cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40' viewBox='0 0 40 40'%3E%3Cg fill='none' stroke='%23140d02' stroke-width='4.5' stroke-linecap='round'%3E%3Ccircle cx='20' cy='20' r='13'/%3E%3Cpath d='M20 2v5M20 33v5M2 20h5M33 20h5'/%3E%3C/g%3E%3Cg fill='none' stroke='%23ffc83d' stroke-width='2' stroke-linecap='round'%3E%3Ccircle cx='20' cy='20' r='13'/%3E%3Cpath d='M20 2v5M20 33v5M2 20h5M33 20h5'/%3E%3C/g%3E%3Cg fill='%23ffc83d' stroke='%23140d02' stroke-width='1'%3E%3Cpath d='M20 0l1.6 3.4L18.4 3.4Z'/%3E%3Cpath d='M20 40l1.6-3.4L18.4 36.6Z'/%3E%3Cpath d='M0 20l3.4-1.6L3.4 21.6Z'/%3E%3Cpath d='M40 20l-3.4-1.6L36.6 21.6Z'/%3E%3C/g%3E%3Cg fill='%23ffc83d' stroke='%23140d02' stroke-width='1'%3E%3Crect x='8.2' y='8.2' width='3.6' height='3.6' transform='rotate(45 10 10)'/%3E%3Crect x='28.2' y='8.2' width='3.6' height='3.6' transform='rotate(45 30 10)'/%3E%3Crect x='8.2' y='28.2' width='3.6' height='3.6' transform='rotate(45 10 30)'/%3E%3Crect x='28.2' y='28.2' width='3.6' height='3.6' transform='rotate(45 30 30)'/%3E%3C/g%3E%3Ccircle cx='20' cy='20' r='2.6' fill='%23ffe9a8' stroke='%23140d02' stroke-width='1'/%3E%3C/svg%3E") 20 20, crosshair;
}
#ui-overlay { #ui-overlay {
position: fixed; top: 0; left: 0; width: 100%; height: 100%; position: fixed; top: 0; left: 0; width: 100%; height: 100%;
pointer-events: none; font-family: monospace; z-index: 10; pointer-events: none; font-family: monospace; z-index: 10;
@@ -50,6 +53,7 @@
#fireball-cooldown { bottom: 72px; } #fireball-cooldown { bottom: 72px; }
#sword-cooldown { bottom: 48px; } #sword-cooldown { bottom: 48px; }
#teleport-cooldown { bottom: 24px; } #teleport-cooldown { bottom: 24px; }
#lightning-cooldown { bottom: 96px; }
#wave-bar-container { #wave-bar-container {
position: absolute; top: 16px; right: 16px; width: 250px; height: 16px; position: absolute; top: 16px; right: 16px; width: 250px; height: 16px;
} }
@@ -70,22 +74,139 @@
text-shadow: 4px 4px 8px #000; text-shadow: 4px 4px 8px #000;
transition: opacity 2s ease-in; transition: opacity 2s ease-in;
} }
#game-over-panel {
position: absolute; top: 62%; left: 50%; transform: translate(-50%, -50%);
display: flex; flex-direction: column; align-items: center; gap: 12px;
pointer-events: none; opacity: 0; transition: opacity 0.8s ease-in 1.5s;
font-family: monospace;
}
#game-over-stats {
color: #ffcc00; font-size: 20px; text-shadow: 2px 2px 4px #000;
}
.menu-button {
pointer-events: auto; cursor: pointer;
background: rgba(0,0,0,0.7); color: #fff; font-family: monospace;
font-size: 18px; padding: 10px 28px; border: 2px solid #888;
}
.menu-button:hover { background: #333; border-color: #ffcc00; color: #ffcc00; }
#main-menu {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 18px; pointer-events: auto; background: rgba(0,0,0,0.55);
transition: opacity 0.6s ease; font-family: monospace;
}
#main-menu h1 {
color: #fff; font-size: 72px; text-shadow: 4px 4px 8px #000;
}
#main-menu .subtitle {
color: #ffcc00; font-size: 20px; text-shadow: 2px 2px 4px #000;
}
#main-menu .menu-controls {
color: rgba(255,255,255,0.8); font-size: 13px; text-align: center;
line-height: 1.7;
}
#btn-start { font-size: 24px; padding: 14px 48px; }
#btn-confirm { font-size: 18px; padding: 10px 36px; }
#btn-confirm:disabled { opacity: 0.35; cursor: default; }
#instructions { #instructions {
position: absolute; bottom: 16px; left: 50%; transform: translateX(-50%); position: absolute; bottom: 16px; left: 50%; transform: translateX(-50%);
color: rgba(255,255,255,0.5); font-size: 11px; font-family: monospace; color: rgba(255,255,255,0.5); font-size: 11px; font-family: monospace;
text-align: center; text-align: center;
} }
#seed-input {
background: rgba(0,0,0,0.7); border: 2px solid #555; color: #ffcc00;
font-family: monospace; font-size: 16px; padding: 6px 10px; width: 180px;
text-align: center; outline: none;
}
#seed-input:focus { border-color: #ffcc00; }
#level-label {
position: absolute; top: 96px; left: 16px; color: #88ff88; font-size: 16px;
font-family: monospace; text-shadow: 2px 2px 4px #000;
}
#seed-label {
position: absolute; top: 96px; right: 16px; color: rgba(255,255,255,0.6);
font-size: 12px; font-family: monospace; text-shadow: 1px 1px 2px #000;
}
#xp-bar-container {
position: absolute; top: 120px; left: 16px; width: 250px; height: 12px;
}
#xp-bar-bg {
width: 100%; height: 100%; background: rgba(0,0,0,0.6); border: 1px solid #555;
}
#xp-bar-fill {
height: 100%; background: #44cc44; transition: width 0.1s linear;
}
#xp-bar-label {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
text-align: center; line-height: 12px; color: #fff; font-size: 10px;
font-family: monospace;
}
#shield-bar-bg {
position: absolute; top: 44px; left: 16px; width: 250px; height: 10px;
background: rgba(0,0,0,0.6); border: 1px solid #555; display: none;
}
#shield-bar {
position: absolute; top: 44px; left: 16px; width: 250px; height: 10px;
background: #3399ff; transition: width 0.1s linear; display: none;
}
#shield-text {
position: absolute; top: 44px; left: 16px; width: 250px; height: 10px;
text-align: center; line-height: 10px; color: #fff; font-size: 9px;
font-family: monospace; display: none;
}
#level-up {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 24px; background: rgba(0,0,0,0.7); opacity: 0; pointer-events: none;
transition: opacity 0.3s ease; font-family: monospace;
}
#level-up h2 {
color: #ffcc00; font-size: 44px; text-shadow: 3px 3px 6px #000;
}
#offer-row {
display: flex; gap: 20px;
}
.offer-card {
width: 230px; min-height: 140px; background: rgba(20,20,30,0.9);
border: 2px solid #888; padding: 14px; cursor: pointer;
display: flex; flex-direction: column; gap: 10px;
transition: transform 0.1s ease, border-color 0.1s ease;
}
.offer-card:hover { border-color: #ffcc00; background: rgba(40,40,50,0.95); }
.offer-card.selected {
border-color: #ffcc00; background: rgba(60,50,20,0.95);
transform: scale(1.04); box-shadow: 0 0 18px rgba(255,204,0,0.4);
}
.offer-title {
color: #ffcc00; font-size: 18px; font-weight: bold;
}
.offer-desc {
color: rgba(255,255,255,0.85); font-size: 13px; line-height: 1.5;
}
.offer-hint {
color: rgba(255,255,255,0.4); font-size: 12px; margin-top: auto;
}
</style> </style>
</head> </head>
<body> <body>
<div id="ui-overlay"> <div id="ui-overlay">
<div id="health-bar-bg"></div> <div id="health-bar-bg"></div>
<div id="health-bar"></div> <div id="health-bar"></div>
<div id="health-text">100%</div> <div id="health-text">100</div>
<div id="shield-bar-bg"></div>
<div id="shield-bar"></div>
<div id="shield-text"></div>
<div id="wave-label">Wave 0</div> <div id="wave-label">Wave 0</div>
<div id="kills-label">Kills: 0</div> <div id="kills-label">Kills: 0</div>
<div id="level-label">Level 0</div>
<div id="xp-bar-container">
<div id="xp-bar-bg"></div>
<div id="xp-bar-fill"></div>
<div id="xp-bar-label">XP: 0/20</div>
</div>
<div id="seed-label"></div>
<div id="fireball-cooldown" class="cooldown-container"> <div id="fireball-cooldown" class="cooldown-container" style="display: none">
<div class="cooldown-bg"></div> <div class="cooldown-bg"></div>
<div class="cooldown-fill"></div> <div class="cooldown-fill"></div>
<div class="cooldown-label">Fireball</div> <div class="cooldown-label">Fireball</div>
@@ -95,11 +216,16 @@
<div class="cooldown-fill"></div> <div class="cooldown-fill"></div>
<div class="cooldown-label">Sword</div> <div class="cooldown-label">Sword</div>
</div> </div>
<div id="teleport-cooldown" class="cooldown-container"> <div id="teleport-cooldown" class="cooldown-container" style="display: none">
<div class="cooldown-bg"></div> <div class="cooldown-bg"></div>
<div class="cooldown-fill"></div> <div class="cooldown-fill"></div>
<div class="cooldown-label">Teleport</div> <div class="cooldown-label">Teleport</div>
</div> </div>
<div id="lightning-cooldown" class="cooldown-container" style="display: none">
<div class="cooldown-bg"></div>
<div class="cooldown-fill"></div>
<div class="cooldown-label">Chain Lightning</div>
</div>
<div id="wave-bar-container"> <div id="wave-bar-container">
<div id="wave-bar-bg"></div> <div id="wave-bar-bg"></div>
@@ -108,8 +234,47 @@
</div> </div>
<div id="game-over">Verloren!</div> <div id="game-over">Verloren!</div>
<div id="game-over-panel">
<div id="game-over-stats"></div>
<button id="btn-restart" class="menu-button">Nochmal spielen</button>
<button id="btn-menu" class="menu-button">Hauptmenü</button>
</div>
<div id="level-up">
<h2>Level Up!</h2>
<div id="offer-row">
<div id="offer-0" class="offer-card">
<div id="offer-title-0" class="offer-title"></div>
<div id="offer-desc-0" class="offer-desc"></div>
<div class="offer-hint">[1] auswählen</div>
</div>
<div id="offer-1" class="offer-card">
<div id="offer-title-1" class="offer-title"></div>
<div id="offer-desc-1" class="offer-desc"></div>
<div class="offer-hint">[2] auswählen</div>
</div>
<div id="offer-2" class="offer-card">
<div id="offer-title-2" class="offer-title"></div>
<div id="offer-desc-2" class="offer-desc"></div>
<div class="offer-hint">[3] auswählen</div>
</div>
</div>
<button id="btn-confirm" class="menu-button" disabled>Bestätigen (Enter)</button>
</div>
<div id="main-menu">
<h1>WACKELPETER</h1>
<div class="subtitle">Ein Blob kämpft ums Überleben</div>
<div class="menu-controls">
WASD: Bewegen · Space: Springen · Maus: Zielen<br>
Linksklick: Schwert · Rechtsklick: Feuerball (per Skill) · Q: Kettenblitz (per Skill) · E: Teleport (per Skill)
</div>
<input id="seed-input" type="text" placeholder="Seed (leer = zufällig)" />
<button id="btn-start" class="menu-button">Start</button>
</div>
<div id="instructions"> <div id="instructions">
WASD: Move · Space: Jump · Mouse: Aim · Left Click: Fireball · Right Click: Sword · M3: Swap hands · E: Teleport WASD: Move · Space: Jump · Mouse: Aim · Left Click: Sword · Right Click: Fireball · Q: Lightning · E: Teleport
</div> </div>
</div> </div>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
+119
View File
@@ -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();
}
}
+146
View File
@@ -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();
}
}
+32
View File
@@ -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();
}
}
+105
View File
@@ -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();
}
}
+157
View File
@@ -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
View File
@@ -0,0 +1,165 @@
import * as THREE from 'three';
import type { Game } from './Game';
import { Enemy } from './Enemy';
export class Crab extends Enemy {
private model: THREE.Group;
private clawL: THREE.Mesh;
private clawR: THREE.Mesh;
private time = Math.random() * Math.PI * 2;
private side = Math.random() < 0.5 ? 1 : -1;
constructor() {
super();
this.health = 90;
this.speed = 4.5;
this.xp = 20;
this.contactRadius = 2.2;
this.model = new THREE.Group();
this.fadeOutModel = this.model;
this.body.add(this.model);
const shellMat = new THREE.MeshToonMaterial({ color: 0xdd5533 });
const darkMat = new THREE.MeshToonMaterial({ color: 0x993322 });
// Shell body
const shell = new THREE.Mesh(new THREE.SphereGeometry(0.55, 18, 14), shellMat);
shell.scale.set(1, 0.55, 0.85);
shell.position.y = 0.35;
shell.castShadow = true;
this.registerFadeMesh(shell);
this.model.add(shell);
// Shell spots
for (const [dx, dz] of [[0, -0.15], [0.25, 0.1], [-0.25, 0.1]] as const) {
const spot = new THREE.Mesh(new THREE.SphereGeometry(0.08, 8, 8), darkMat);
spot.scale.set(1, 0.4, 1);
spot.position.set(dx, 0.62, dz);
this.registerFadeMesh(spot);
this.model.add(spot);
}
// Eye stalks (on the armored front side, -Z)
const stalkMat = new THREE.MeshToonMaterial({ color: 0xdd5533 });
const eyeMat = new THREE.MeshToonMaterial({ color: 0x111122 });
for (const side of [-1, 1]) {
const stalk = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.035, 0.22, 6), stalkMat);
stalk.position.set(side * 0.22, 0.55, -0.42);
this.registerFadeMesh(stalk);
this.model.add(stalk);
const eye = new THREE.Mesh(new THREE.SphereGeometry(0.09, 10, 8), eyeMat);
eye.position.set(side * 0.22, 0.68, -0.42);
this.registerFadeMesh(eye);
this.model.add(eye);
}
// Legs
const legGeom = new THREE.CapsuleGeometry(0.05, 0.25, 4, 8);
for (const side of [-1, 1]) {
for (const legZ of [-0.3, 0.3]) {
const leg = new THREE.Mesh(legGeom, shellMat);
leg.position.set(side * 0.45, 0.15, legZ);
leg.rotation.z = side * 0.6;
this.registerFadeMesh(leg);
this.model.add(leg);
}
}
// Big claws (front, open/close)
const clawGeom = new THREE.SphereGeometry(0.22, 12, 10);
this.clawL = new THREE.Mesh(clawGeom, shellMat);
this.clawR = new THREE.Mesh(clawGeom, shellMat);
this.clawL.position.set(-0.45, 0.45, -0.5);
this.clawR.position.set(0.45, 0.45, -0.5);
this.clawL.scale.set(1.2, 0.7, 1.4);
this.clawR.scale.set(1.2, 0.7, 1.4);
this.clawL.castShadow = true;
this.clawR.castShadow = true;
this.registerFadeMesh(this.clawL);
this.registerFadeMesh(this.clawR);
this.model.add(this.clawL, this.clawR);
}
playAnim() {
// Procedural visuals only
}
updateAnimation(dt: number) {
this.updateFadeDeath(dt, { duration: 0.8, sink: 0.4 });
}
update(dt: number, game: Game) {
this.time += dt;
// Claw pinch
const pinch = 1 + Math.sin(this.time * 4) * 0.35;
this.clawL.scale.set(1.2, 0.7 * pinch, 1.4);
this.clawR.scale.set(1.2, 0.7 * pinch, 1.4);
const toPlayer = new THREE.Vector3()
.subVectors(game.player.body.position, this.body.position);
toPlayer.y = 0;
const dist = toPlayer.length();
// Face the player
if (dist > 0.1) {
this.body.lookAt(
this.body.position.x + toPlayer.x,
this.body.position.y,
this.body.position.z + toPlayer.z
);
}
// Strafe sideways around the player
if (dist > 3) {
const perp = new THREE.Vector3(-toPlayer.z, 0, toPlayer.x).normalize();
if (this.knockback > 0) {
this.knockback -= dt;
const away = toPlayer.clone().normalize().multiplyScalar(-this.speed);
this.body.position.x += away.x * dt;
this.body.position.z += away.z * dt;
} else {
// Flip side occasionally to circle
if (Math.random() < dt * 0.3) this.side *= -1;
this.body.position.x += perp.x * this.side * this.speed * dt;
this.body.position.z += perp.z * this.side * this.speed * dt;
}
game.clampToArena(this.body.position);
} else if (dist > 0.1) {
const dir = toPlayer.normalize();
if (this.knockback > 0) {
this.knockback -= dt;
this.body.position.x -= dir.x * this.speed * dt;
this.body.position.z -= dir.z * this.speed * dt;
}
}
}
takeDamage(
damage: number,
game: Game,
withKnockback = true,
sourcePos?: THREE.Vector3
) {
// Armored from the front: reduce damage coming from the facing side
if (sourcePos) {
const front = new THREE.Vector3(0, 0, -1)
.applyQuaternion(this.body.quaternion);
front.y = 0;
front.normalize();
const incoming = new THREE.Vector3()
.subVectors(sourcePos, this.body.position);
incoming.y = 0;
incoming.normalize();
if (front.dot(incoming) > 0.3) {
damage *= 0.4;
}
}
super.takeDamage(damage, game, withKnockback, sourcePos);
}
protected onDeath(_game: Game) {
this.beginFadeDeath();
}
}
+81 -100
View File
@@ -1,123 +1,97 @@
import * as THREE from 'three'; import * as THREE from 'three';
import type { Game } from './Game'; import type { Game } from './Game';
import { playSound, playLoopingSound } from './SoundManager'; import { playSound } from './SoundManager';
import { loadGLB } from './MeshLoader';
import type { GLTF } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { clone as cloneSkeleton } from 'three/examples/jsm/utils/SkeletonUtils.js';
let spiderGLTF: GLTF | null = null; export abstract class Enemy {
const ANIMATIONS_ENABLED = true;
export async function initSpiderModel() {
try {
spiderGLTF = await loadGLB('spider');
} catch {
console.warn('Failed to load spider model');
}
}
export class Enemy {
body: THREE.Group; body: THREE.Group;
dead = false; dead = false;
deathTimer = 0; deathTimer = 0;
knockback = 0; knockback = 0;
health = 100; health = 100;
speed = 6;
xp = 10;
contactDps = 25;
contactRadius = 2;
guaranteedDrop = false;
grace = 0;
mixer: THREE.AnimationMixer | null = null; mixer: THREE.AnimationMixer | null = null;
private currentAnim = ''; protected clips: Map<string, THREE.AnimationAction> = new Map();
private clips: Map<string, THREE.AnimationAction> = new Map(); protected currentAnim = '';
private stopWalkSound: (() => void) | null = null; protected stopWalkSound: (() => void) | null = null;
protected fadeOutModel: THREE.Group | null = null;
protected fadeMaterials: THREE.Material[] = [];
private dying = false;
private dieTime = 0;
constructor(_playerPos: THREE.Vector3) { constructor() {
this.body = new THREE.Group(); this.body = new THREE.Group();
this.stopWalkSound = playLoopingSound('spiderwalking', 0.3); }
if (spiderGLTF) { abstract playAnim(name: string, loop?: boolean, speed?: number): void;
const model = cloneSkeleton(spiderGLTF.scene); abstract updateAnimation(dt: number): void;
// Original game scaled the spider model by 0.5 (WalkingEnemy.setupModel) abstract update(dt: number, game: Game): void;
model.scale.set(0.5, 0.5, 0.5);
const spiderTexture = new THREE.TextureLoader().load('./textures/spider_animation2.png');
spiderTexture.flipY = false;
spiderTexture.colorSpace = THREE.SRGBColorSpace;
model.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.castShadow = true;
child.material = new THREE.MeshToonMaterial({ map: spiderTexture });
}
});
this.body.add(model);
if (spiderGLTF.animations.length > 0 && ANIMATIONS_ENABLED) { canBeSlashHit(): boolean {
this.mixer = new THREE.AnimationMixer(model); return true;
for (const clip of spiderGLTF.animations) { }
const action = this.mixer.clipAction(clip);
this.clips.set(clip.name, action); protected onDeath(game: Game) {
} // Subclasses override for model-specific death behavior
this.playAnim('stand'); }
}
protected registerFadeMesh(mesh: THREE.Mesh) {
const mat = mesh.material as THREE.Material & {
transparent: boolean;
opacity: number;
};
mat.transparent = true;
this.fadeMaterials.push(mat);
}
protected beginFadeDeath() {
this.dying = true;
this.dieTime = 0;
}
protected updateFadeDeath(
dt: number,
opts?: { duration?: number; shrink?: number; sink?: number }
): boolean {
if (!this.dying) return false;
const duration = opts?.duration ?? 0.9;
this.dieTime += dt;
const t = Math.min(this.dieTime / duration, 1);
const scale = Math.max(0.05, 1 - t * (opts?.shrink ?? 0.95));
if (this.fadeOutModel) {
this.fadeOutModel.scale.set(scale, scale, scale);
} else { } else {
// Fallback this.body.scale.set(scale, scale, scale);
const abdomenGeom = new THREE.SphereGeometry(0.5, 8, 8);
const bodyMat = new THREE.MeshToonMaterial({ color: 0x333333 });
const abdomen = new THREE.Mesh(abdomenGeom, bodyMat);
abdomen.scale.set(1, 0.6, 1.3);
abdomen.position.y = 0.6;
abdomen.castShadow = true;
this.body.add(abdomen);
const cephalothoraxGeom = new THREE.SphereGeometry(0.3, 8, 8);
const cephalothorax = new THREE.Mesh(cephalothoraxGeom, bodyMat);
cephalothorax.position.y = 0.6;
cephalothorax.position.z = 0.5;
cephalothorax.castShadow = true;
this.body.add(cephalothorax);
const eyeGeom = new THREE.SphereGeometry(0.06, 6, 6);
const eyeMat = new THREE.MeshBasicMaterial({ color: 0xff0000 });
for (const sign of [-1, 1]) {
const eye = new THREE.Mesh(eyeGeom, eyeMat);
eye.position.set(sign * 0.12, 0.85, 0.7);
this.body.add(eye);
}
const legGeom = new THREE.CylinderGeometry(0.04, 0.04, 0.8, 4);
for (let i = 0; i < 8; i++) {
const leg = new THREE.Mesh(legGeom, new THREE.MeshToonMaterial({ color: 0x222222 }));
const angle = (i / 8) * Math.PI * 2;
const side = i < 4 ? 1 : -1;
leg.position.set(Math.cos(angle) * 0.35, 0.3, Math.sin(angle) * 0.35);
leg.rotation.z = side * 0.6;
leg.rotation.x = angle;
leg.castShadow = true;
this.body.add(leg);
}
} }
} const sink = opts?.sink ?? 0;
if (sink > 0) {
playAnim(name: string, loop = true, speed = 1) { this.body.position.y = Math.max(-1, this.body.position.y - sink * dt);
if (!this.clips.has(name) || this.currentAnim === name) return;
// Stop the previous animation so it doesn't blend with the new one
if (this.currentAnim) {
const prev = this.clips.get(this.currentAnim);
if (prev) prev.stop();
} }
this.currentAnim = name; const opacity = 1 - t;
const action = this.clips.get(name)!; for (const mat of this.fadeMaterials) {
action.reset(); (mat as THREE.Material & { opacity: number }).opacity = opacity;
action.setLoop(loop ? THREE.LoopRepeat : THREE.LoopOnce, loop ? Infinity : 1); }
action.clampWhenFinished = !loop; return true;
action.setEffectiveTimeScale(speed);
action.play();
} }
updateAnimation(dt: number) { takeDamage(
if (this.mixer) this.mixer.update(dt); damage: number,
} game: Game,
withKnockback = true,
takeDamage(damage: number, game: Game) { sourcePos?: THREE.Vector3
if (this.dead) return; ) {
if (this.dead || this.grace > 0) return;
this.health -= damage; this.health -= damage;
this.knockback = 0.5; if (withKnockback) {
this.knockback = 0.5;
}
playSound('damage', 0.7); playSound('damage', 0.7);
game.onDamageDealt(damage);
if (this.health <= 0) { if (this.health <= 0) {
this.die(game); this.die(game);
@@ -127,11 +101,18 @@ export class Enemy {
private die(game: Game) { private die(game: Game) {
if (this.dead) return; if (this.dead) return;
this.dead = true; this.dead = true;
this.playAnim('die', false); this.onDeath(game);
if (this.stopWalkSound) { if (this.stopWalkSound) {
this.stopWalkSound(); this.stopWalkSound();
this.stopWalkSound = null; this.stopWalkSound = null;
} }
game.removeEnemy(this); game.removeEnemy(this);
} }
dispose() {
if (this.stopWalkSound) {
this.stopWalkSound();
this.stopWalkSound = null;
}
}
} }
+76
View File
@@ -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();
}
}
}
}
+2
View File
@@ -12,6 +12,8 @@ export class Fireball {
exploding = false; exploding = false;
timer = 0; timer = 0;
damageDone = false; damageDone = false;
damage = 100;
explosionRadius = 8;
private trailParticles: THREE.Points; private trailParticles: THREE.Points;
private trailVelocities: Float32Array; private trailVelocities: Float32Array;
private trailLives: Float32Array; private trailLives: Float32Array;
+285
View File
@@ -0,0 +1,285 @@
import * as THREE from 'three';
import type { Game } from './Game';
import { Enemy } from './Enemy';
import { playSound } from './SoundManager';
const JUMP_DISTANCE = 6.5;
const JUMP_HEIGHT = 2.6;
const JUMP_DURATION = 0.65;
const CROUCH_TIME = 0.35;
const LAND_TIME = 0.3;
const PAUSE_TIME = 0.6;
type FrogState = 'crouch' | 'leap' | 'land' | 'pause';
export class Frog extends Enemy {
private model: THREE.Group;
private bodyMesh: THREE.Mesh;
private eyeL: THREE.Mesh;
private eyeR: THREE.Mesh;
private legs: THREE.Mesh[] = [];
private shadow: THREE.Mesh;
private shadowMat: THREE.MeshBasicMaterial;
private whiteMat: THREE.MeshToonMaterial;
private state: FrogState = 'pause';
private stateTimer = 0.4;
private jumpFrom = new THREE.Vector3();
private jumpTo = new THREE.Vector3();
private jumpProgress = 0;
private airborne = false;
private hopTime = 0;
constructor() {
super();
this.health = 35;
this.xp = 12;
this.model = new THREE.Group();
this.fadeOutModel = this.model;
this.body.add(this.model);
const greenMat = new THREE.MeshToonMaterial({ color: 0x55cc33 });
this.whiteMat = new THREE.MeshToonMaterial({ color: 0xf0fff0 });
// Squat body
this.bodyMesh = new THREE.Mesh(new THREE.SphereGeometry(0.55, 20, 16), greenMat);
this.bodyMesh.scale.set(1, 0.72, 1.1);
this.bodyMesh.position.y = 0.42;
this.bodyMesh.castShadow = true;
this.registerFadeMesh(this.bodyMesh);
this.model.add(this.bodyMesh);
// Belly
const belly = new THREE.Mesh(new THREE.SphereGeometry(0.4, 16, 12), this.whiteMat);
belly.scale.set(0.85, 0.5, 0.85);
belly.position.set(0, 0.3, -0.42);
this.registerFadeMesh(belly);
this.model.add(belly);
// Bulging eyes (face toward the player: -Z)
const eyeGeom = new THREE.SphereGeometry(0.17, 14, 12);
const pupilGeom = new THREE.SphereGeometry(0.08, 10, 8);
const pupilMat = new THREE.MeshToonMaterial({ color: 0x111122 });
this.eyeL = new THREE.Mesh(eyeGeom, this.whiteMat);
this.eyeR = new THREE.Mesh(eyeGeom, this.whiteMat);
this.eyeL.position.set(-0.24, 0.72, -0.28);
this.eyeR.position.set(0.24, 0.72, -0.28);
this.eyeL.castShadow = true;
this.eyeR.castShadow = true;
this.registerFadeMesh(this.eyeL);
this.registerFadeMesh(this.eyeR);
this.model.add(this.eyeL, this.eyeR);
for (const eye of [this.eyeL, this.eyeR]) {
const pupil = new THREE.Mesh(pupilGeom, pupilMat);
pupil.position.set(0, 0.02, -0.12);
this.registerFadeMesh(pupil);
eye.add(pupil);
}
// Folded back legs
const legGeom = new THREE.SphereGeometry(0.16, 12, 10);
for (const side of [-1, 1]) {
const thigh = new THREE.Mesh(legGeom, greenMat);
thigh.scale.set(0.8, 1, 1.2);
thigh.position.set(side * 0.42, 0.25, 0.35);
thigh.rotation.y = side * 0.5;
thigh.castShadow = true;
this.registerFadeMesh(thigh);
this.model.add(thigh);
this.legs.push(thigh);
}
// Blob shadow on the ground
this.shadowMat = new THREE.MeshBasicMaterial({
color: 0x000000,
transparent: true,
opacity: 0.25,
});
this.shadow = new THREE.Mesh(new THREE.CircleGeometry(0.5, 20), this.shadowMat);
this.shadow.rotation.x = -Math.PI / 2;
this.shadow.renderOrder = 998;
this.body.add(this.shadow);
}
playAnim() {
// No rigged animations, visuals are procedural
}
updateAnimation(dt: number) {
this.updateFadeDeath(dt, { duration: 0.8, sink: 0.6 });
}
update(dt: number, game: Game) {
this.hopTime += dt;
// Knocked back: fall to the ground and slide away from the player
if (this.knockback > 0) {
this.knockback -= dt;
this.airborne = false;
this.state = 'pause';
this.stateTimer = 0.3;
const away = new THREE.Vector3()
.subVectors(this.body.position, game.player.body.position);
away.y = 0;
away.normalize().multiplyScalar(this.speed);
this.body.position.x += away.x * dt;
this.body.position.z += away.z * dt;
this.body.position.y = 0.1;
} else {
switch (this.state) {
case 'pause':
this.body.position.y = 0.1;
this.stateTimer -= dt;
if (this.stateTimer <= 0) {
this.state = 'crouch';
this.stateTimer = CROUCH_TIME;
}
break;
case 'crouch': {
this.body.position.y = 0.1;
const target = game.player.body.position.clone();
const toPlayer = target.sub(this.body.position);
toPlayer.y = 0;
if (toPlayer.length() > JUMP_DISTANCE) {
toPlayer.normalize().multiplyScalar(JUMP_DISTANCE);
}
this.jumpFrom.copy(this.body.position);
this.jumpTo.set(
this.body.position.x + toPlayer.x,
0.1,
this.body.position.z + toPlayer.z
);
this.body.lookAt(this.jumpTo.x, this.body.position.y, this.jumpTo.z);
this.stateTimer -= dt;
if (this.stateTimer <= 0) {
this.state = 'leap';
this.jumpProgress = 0;
this.airborne = true;
}
break;
}
case 'leap': {
this.jumpProgress += dt / JUMP_DURATION;
const t = Math.min(this.jumpProgress, 1);
this.body.position.x = THREE.MathUtils.lerp(this.jumpFrom.x, this.jumpTo.x, t);
this.body.position.z = THREE.MathUtils.lerp(this.jumpFrom.z, this.jumpTo.z, t);
this.body.position.y = 0.1 + Math.sin(t * Math.PI) * JUMP_HEIGHT;
if (this.jumpProgress >= 1) {
this.airborne = false;
this.state = 'land';
this.stateTimer = LAND_TIME;
this.spawnDust(game.scene);
}
break;
}
case 'land':
this.body.position.y = 0.1;
this.stateTimer -= dt;
if (this.stateTimer <= 0) {
this.state = 'pause';
this.stateTimer = PAUSE_TIME + Math.random() * 0.5;
}
break;
}
}
// Squash & stretch animation
let sy = 1;
let sxz = 1;
if (this.state === 'crouch') {
sy = 0.62;
sxz = 1.25;
} else if (this.state === 'leap') {
sy = 1.3;
sxz = 0.8;
} else if (this.state === 'land') {
sy = 0.7;
sxz = 1.25;
}
this.model.scale.set(sxz, sy, sxz);
this.eyeL.position.y = 0.72 * sy;
this.eyeR.position.y = 0.72 * sy;
// Leg twitch
for (let i = 0; i < this.legs.length; i++) {
const side = i === 0 ? -1 : 1;
this.legs[i].rotation.z = side * Math.sin(this.hopTime * 8) * 0.1;
}
// Shadow stays on the ground
this.shadow.position.y = 0.06 - this.body.position.y;
const shadowScale = 1 - (this.body.position.y - 0.1) / (JUMP_HEIGHT * 2);
this.shadowMat.opacity = 0.25 * Math.max(0.4, shadowScale);
game.clampToArena(this.body.position);
}
canBeSlashHit(): boolean {
return !this.airborne;
}
protected onDeath(game: Game) {
playSound('spawn', 0.4);
this.beginFadeDeath();
if (this.body.position.y > 0.15) {
this.body.position.y = 0.15;
}
void game;
}
private spawnDust(scene: THREE.Scene) {
const count = 10;
const positions = new Float32Array(count * 3);
const velocities: THREE.Vector3[] = [];
const lives = new Float32Array(count).fill(0.4);
for (let i = 0; i < count; i++) {
positions[i * 3] = this.body.position.x;
positions[i * 3 + 1] = 0.15;
positions[i * 3 + 2] = this.body.position.z;
const angle = Math.random() * Math.PI * 2;
velocities.push(new THREE.Vector3(
Math.cos(angle) * 1.2,
Math.random() * 1.5,
Math.sin(angle) * 1.2
));
}
const geom = new THREE.BufferGeometry();
geom.setAttribute('position', new THREE.BufferAttribute(positions, 3));
const mat = new THREE.PointsMaterial({
color: 0xaa9988,
size: 0.15,
transparent: true,
depthWrite: false,
});
const points = new THREE.Points(geom, mat);
points.renderOrder = 999;
scene.add(points);
const startTime = performance.now();
const update = () => {
const elapsed = (performance.now() - startTime) / 1000;
if (elapsed > 0.5) {
points.removeFromParent();
geom.dispose();
mat.dispose();
return;
}
const arr = geom.getAttribute('position') as THREE.BufferAttribute;
for (let i = 0; i < count; i++) {
lives[i] -= 0.016;
if (lives[i] <= 0) continue;
arr.array[i * 3] += velocities[i].x * 0.016;
arr.array[i * 3 + 1] += velocities[i].y * 0.016;
arr.array[i * 3 + 2] += velocities[i].z * 0.016;
}
arr.needsUpdate = true;
mat.opacity = Math.max(0, 1 - elapsed / 0.5);
requestAnimationFrame(update);
};
update();
}
}
+491 -68
View File
@@ -1,15 +1,30 @@
import * as THREE from 'three'; import * as THREE from 'three';
import { Player } from './Player'; import { Player } from './Player';
import { Enemy, initSpiderModel } from './Enemy'; import { Enemy } from './Enemy';
import { Spider, initSpiderModel } from './Spider';
import { Ghost } from './Ghost';
import { Frog } from './Frog';
import { Bat } from './Bat';
import { BeeSwarm } from './BeeSwarm';
import { Slime } from './Slime';
import { Brute } from './Brute';
import { Boss } from './Boss';
import { Crab } from './Crab';
import { Turret } from './Turret';
import { EnemyProjectile } from './EnemyProjectile';
import { Sword } from './Sword'; import { Sword } from './Sword';
import { Staff } from './Staff'; import { Staff } from './Staff';
import { Fireball } from './Fireball'; import { Fireball } from './Fireball';
import { Cross, initCrossModel } from './Cross'; import { Cross, initCrossModel } from './Cross';
import { Arena } from './Arena'; import { Arena } from './Arena';
import { SlashEffect, isInSlashArc } from './SwordTrail';
import { SkillSystem, getSkill, type SkillOffer } from './Skills';
import { initSounds, playSound, resumeContext } from './SoundManager'; import { initSounds, playSound, resumeContext } from './SoundManager';
const SPAWN_TIME = 10; const SPAWN_TIME = 10;
type GameState = 'menu' | 'playing' | 'gameover' | 'levelup';
export class Game { export class Game {
scene: THREE.Scene; scene: THREE.Scene;
camera: THREE.PerspectiveCamera; camera: THREE.PerspectiveCamera;
@@ -20,6 +35,16 @@ export class Game {
enemies: Enemy[] = []; enemies: Enemy[] = [];
fireballs: Fireball[] = []; fireballs: Fireball[] = [];
crosses: Cross[] = []; crosses: Cross[] = [];
slashEffects: SlashEffect[] = [];
projectiles: EnemyProjectile[] = [];
state: GameState = 'menu';
skillSystem: SkillSystem = new SkillSystem(0);
private seed: number | null = null;
private offers: SkillOffer[] = [];
private selectedOffer = -1;
private pendingPicks = 0;
private gameOverAt = 0;
keys: Set<string> = new Set(); keys: Set<string> = new Set();
mouseButtons: Set<number> = new Set(); mouseButtons: Set<number> = new Set();
@@ -31,7 +56,6 @@ export class Game {
spawnTimer = 0; spawnTimer = 0;
spawns = 0; spawns = 0;
killed = 0; killed = 0;
gameOver = false;
// Debug hitzone visualization // Debug hitzone visualization
private showHitzones = false; private showHitzones = false;
@@ -46,11 +70,30 @@ export class Game {
private uiWaveLabel!: HTMLElement; private uiWaveLabel!: HTMLElement;
private uiKillsLabel!: HTMLElement; private uiKillsLabel!: HTMLElement;
private uiFireballCD!: HTMLElement; private uiFireballCD!: HTMLElement;
private uiFireballContainer!: HTMLElement;
private uiSwordCD!: HTMLElement; private uiSwordCD!: HTMLElement;
private uiTeleportCD!: HTMLElement; private uiTeleportCD!: HTMLElement;
private uiTeleportContainer!: HTMLElement;
private uiLightningCD!: HTMLElement;
private uiLightningContainer!: HTMLElement;
private uiWaveBarFill!: HTMLElement; private uiWaveBarFill!: HTMLElement;
private uiWaveBarLabel!: HTMLElement; private uiWaveBarLabel!: HTMLElement;
private uiGameOver!: HTMLElement; private uiGameOver!: HTMLElement;
private uiGameOverPanel!: HTMLElement;
private uiGameOverStats!: HTMLElement;
private uiMainMenu!: HTMLElement;
private uiSeedInput!: HTMLInputElement;
private uiLevelLabel!: HTMLElement;
private uiSeedLabel!: HTMLElement;
private uiXpBarFill!: HTMLElement;
private uiXpBarLabel!: HTMLElement;
private uiShieldBar!: HTMLElement;
private uiShieldBarBg!: HTMLElement;
private uiShieldText!: HTMLElement;
private uiLevelUp!: HTMLElement;
private uiConfirmBtn!: HTMLButtonElement;
private uiOfferTitles: HTMLElement[] = [];
private uiOfferDescs: HTMLElement[] = [];
private mouseOnCanvas = false; private mouseOnCanvas = false;
constructor() { constructor() {
@@ -110,32 +153,94 @@ export class Game {
this.uiWaveLabel = document.getElementById('wave-label')!; this.uiWaveLabel = document.getElementById('wave-label')!;
this.uiKillsLabel = document.getElementById('kills-label')!; this.uiKillsLabel = document.getElementById('kills-label')!;
this.uiFireballCD = document.querySelector('#fireball-cooldown .cooldown-fill')!; this.uiFireballCD = document.querySelector('#fireball-cooldown .cooldown-fill')!;
this.uiFireballContainer = document.getElementById('fireball-cooldown')!;
this.uiSwordCD = document.querySelector('#sword-cooldown .cooldown-fill')!; this.uiSwordCD = document.querySelector('#sword-cooldown .cooldown-fill')!;
this.uiTeleportCD = document.querySelector('#teleport-cooldown .cooldown-fill')!; this.uiTeleportCD = document.querySelector('#teleport-cooldown .cooldown-fill')!;
this.uiTeleportContainer = document.getElementById('teleport-cooldown')!;
this.uiLightningCD = document.querySelector('#lightning-cooldown .cooldown-fill')!;
this.uiLightningContainer = document.getElementById('lightning-cooldown')!;
this.uiWaveBarFill = document.getElementById('wave-bar-fill')!; this.uiWaveBarFill = document.getElementById('wave-bar-fill')!;
this.uiWaveBarLabel = document.getElementById('wave-bar-label')!; this.uiWaveBarLabel = document.getElementById('wave-bar-label')!;
this.uiGameOver = document.getElementById('game-over')!; this.uiGameOver = document.getElementById('game-over')!;
this.uiGameOverPanel = document.getElementById('game-over-panel')!;
this.uiGameOverStats = document.getElementById('game-over-stats')!;
this.uiMainMenu = document.getElementById('main-menu')!;
this.uiSeedInput = document.getElementById('seed-input') as HTMLInputElement;
this.uiLevelLabel = document.getElementById('level-label')!;
this.uiSeedLabel = document.getElementById('seed-label')!;
this.uiXpBarFill = document.getElementById('xp-bar-fill')!;
this.uiXpBarLabel = document.getElementById('xp-bar-label')!;
this.uiShieldBar = document.getElementById('shield-bar')!;
this.uiShieldBarBg = document.getElementById('shield-bar-bg')!;
this.uiShieldText = document.getElementById('shield-text')!;
this.uiLevelUp = document.getElementById('level-up')!;
this.uiConfirmBtn = document.getElementById('btn-confirm') as HTMLButtonElement;
this.uiConfirmBtn.addEventListener('click', () => this.confirmOffer());
for (let i = 0; i < 3; i++) {
const card = document.getElementById(`offer-${i}`)!;
card.addEventListener('click', () => this.selectOffer(i));
this.uiOfferTitles.push(document.getElementById(`offer-title-${i}`)!);
this.uiOfferDescs.push(document.getElementById(`offer-desc-${i}`)!);
}
document.getElementById('btn-start')!.addEventListener('click', () => this.startGame());
document.getElementById('btn-restart')!.addEventListener('click', () => this.restart());
document.getElementById('btn-menu')!.addEventListener('click', () => this.showMenu());
// Diagnostics: detect a full page reload that wiped a running game
if (sessionStorage.getItem('wackelpeter-run') === '1') {
console.warn('[Wackelpeter] Seite wurde während eines Laufs neu geladen!');
sessionStorage.removeItem('wackelpeter-run');
}
window.addEventListener('beforeunload', () => {
console.log('[Wackelpeter] beforeunload (Seite wird neu geladen/geschlossen)');
});
const canvas = this.renderer.domElement; const canvas = this.renderer.domElement;
canvas.addEventListener('mouseenter', () => this.mouseOnCanvas = true); canvas.addEventListener('mouseenter', () => this.mouseOnCanvas = true);
canvas.addEventListener('mouseleave', () => this.mouseOnCanvas = false); canvas.addEventListener('mouseleave', () => this.mouseOnCanvas = false);
canvas.addEventListener('contextmenu', e => e.preventDefault()); window.addEventListener('contextmenu', e => e.preventDefault());
}
private logState(next: GameState, reason: string) {
console.log(`[Wackelpeter] state: ${this.state}${next} (${reason})`);
} }
private setupInput() { private setupInput() {
window.addEventListener('keydown', (e) => { window.addEventListener('keydown', (e) => {
this.keys.add(e.code); this.keys.add(e.code);
if (e.code === 'Space') { if (e.code === 'Space' && this.state === 'playing') {
this.player.jump(); this.player.jump();
e.preventDefault(); e.preventDefault();
} }
if (e.code === 'KeyH') { if (e.code === 'KeyH') {
this.showHitzones = !this.showHitzones; this.showHitzones = !this.showHitzones;
} }
if (e.code === 'KeyQ' && this.state === 'playing') {
this.player.skillLightning.skill1(this.groundPoint, this.player);
}
if (this.state === 'levelup') {
if (e.code === 'Digit1' || e.code === 'Numpad1') this.selectOffer(0);
else if (e.code === 'Digit2' || e.code === 'Numpad2') this.selectOffer(1);
else if (e.code === 'Digit3' || e.code === 'Numpad3') this.selectOffer(2);
else if (e.code === 'Enter') this.confirmOffer();
e.preventDefault();
return;
}
if (e.code === 'Enter' || e.code === 'Space') {
if (this.state === 'menu') this.startGame();
else if (
this.state === 'gameover' &&
performance.now() - this.gameOverAt > 1500
) {
this.restart();
}
}
}); });
window.addEventListener('keyup', (e) => { window.addEventListener('keyup', (e) => {
this.keys.delete(e.code); this.keys.delete(e.code);
if (e.code === 'KeyE' && !this.gameOver) { if (e.code === 'KeyE' && this.state === 'playing') {
this.player.leftHandWeapon.skill2(this.groundPoint, this.player); this.player.leftHandWeapon.skill2(this.groundPoint, this.player);
} }
}); });
@@ -157,13 +262,11 @@ export class Game {
} }
private handleMouseRelease(button: number) { private handleMouseRelease(button: number) {
if (this.gameOver) return; if (this.state !== 'playing') return;
if (button === 0) { if (button === 0) {
this.player.leftHandWeapon.skill1(this.groundPoint, this.player);
} else if (button === 1) {
this.player.switchHands();
} else if (button === 2) {
this.player.rightHandWeapon.skill1(this.groundPoint, this.player); this.player.rightHandWeapon.skill1(this.groundPoint, this.player);
} else if (button === 2) {
this.player.leftHandWeapon.skill1(this.groundPoint, this.player);
} }
} }
@@ -286,15 +389,287 @@ export class Game {
} }
} }
startGame() {
if (this.state === 'playing' || this.state === 'levelup') return;
if (this.seed === null) {
const input = this.uiSeedInput.value.trim();
if (input !== '' && !Number.isNaN(Number(input))) {
this.seed = Number(input) >>> 0;
} else {
this.seed = Math.floor(Math.random() * 1000000);
}
}
this.resetWorld();
this.logState('playing', 'startGame');
this.state = 'playing';
sessionStorage.setItem('wackelpeter-run', '1');
this.uiMainMenu.style.opacity = '0';
this.uiMainMenu.style.pointerEvents = 'none';
this.uiGameOver.style.opacity = '0';
this.uiGameOverPanel.style.transition = 'opacity 0.3s ease';
this.uiGameOverPanel.style.opacity = '0';
this.uiGameOverPanel.style.pointerEvents = 'none';
this.blurFocus();
}
private blurFocus() {
const el = document.activeElement;
if (el instanceof HTMLElement) el.blur();
}
restart() {
this.startGame();
}
showMenu() {
this.resetWorld();
this.logState('menu', 'showMenu');
this.state = 'menu';
this.seed = null;
sessionStorage.removeItem('wackelpeter-run');
this.uiGameOver.style.opacity = '0';
this.uiGameOverPanel.style.transition = 'opacity 0.3s ease';
this.uiGameOverPanel.style.opacity = '0';
this.uiGameOverPanel.style.pointerEvents = 'none';
this.uiMainMenu.style.opacity = '1';
this.uiMainMenu.style.pointerEvents = 'auto';
this.blurFocus();
}
private gameOver() {
this.logState('gameover', 'gameOver');
this.state = 'gameover';
this.gameOverAt = performance.now();
this.player.die();
this.blurFocus();
this.uiGameOver.style.opacity = '1';
this.uiGameOverStats.textContent = `Wave ${this.spawns} · Kills: ${this.killed} · Level ${this.skillSystem.level}`;
this.uiGameOverPanel.style.transition = 'opacity 0.8s ease-in 1.5s';
this.uiGameOverPanel.style.opacity = '1';
this.uiGameOverPanel.style.pointerEvents = 'auto';
playSound('gameover', 0.8);
}
private resetWorld() {
for (const enemy of this.enemies) {
enemy.dispose();
this.scene.remove(enemy.body);
}
this.enemies = [];
for (const fb of this.fireballs) this.scene.remove(fb.body);
this.fireballs = [];
for (const cross of this.crosses) this.scene.remove(cross.body);
this.crosses = [];
for (const slash of this.slashEffects) slash.dispose();
this.slashEffects = [];
for (const p of this.projectiles) p.dispose();
this.projectiles = [];
for (const [enemy, ring] of this.enemyHitRings) {
this.scene.remove(ring);
ring.geometry.dispose();
(ring.material as THREE.Material).dispose();
this.enemyHitRings.delete(enemy);
}
for (const [fb, ring] of this.fireballHitRings) {
this.scene.remove(ring);
ring.geometry.dispose();
(ring.material as THREE.Material).dispose();
this.fireballHitRings.delete(fb);
}
for (const [cross, ring] of this.crossHitRings) {
this.scene.remove(ring);
ring.geometry.dispose();
(ring.material as THREE.Material).dispose();
this.crossHitRings.delete(cross);
}
this.spawnTimer = 0;
this.spawns = 0;
this.killed = 0;
this.pendingPicks = 0;
this.hideLevelUp();
this.skillSystem = new SkillSystem(this.seed ?? 0);
this.player.reset();
this.player.applySkillSystem(this.skillSystem);
this.syncSkillVisibility();
this.player.rightHandWeapon.cooldown1 = 0;
this.player.rightHandWeapon.cooldown2 = 0;
this.player.rightHandWeapon.cooldown3 = 0;
this.player.leftHandWeapon.cooldown1 = 0;
this.player.leftHandWeapon.cooldown2 = 0;
this.player.leftHandWeapon.cooldown3 = 0;
this.player.skillLightning.cooldown1 = 0;
this.player.skillLightning.cooldown2 = 0;
this.player.skillLightning.cooldown3 = 0;
this.updateUI();
}
onDamageDealt(damage: number) {
if (this.state === 'playing' && this.player.stats.lifesteal > 0) {
this.player.heal(damage * this.player.stats.lifesteal);
}
}
private openLevelUp() {
this.offers = this.skillSystem.rollOffers();
if (this.offers.length === 0) {
// Everything maxed out: no picks left, just continue playing
this.pendingPicks = 0;
this.hideLevelUp();
this.logState('playing', 'openLevelUp (keine Angebote)');
this.state = 'playing';
return;
}
this.logState('levelup', 'openLevelUp');
this.state = 'levelup';
this.selectedOffer = -1;
this.blurFocus();
playSound('spawn', 0.6);
for (let i = 0; i < 3; i++) {
const title = this.uiOfferTitles[i];
const desc = this.uiOfferDescs[i];
const card = document.getElementById(`offer-${i}`)!;
if (i < this.offers.length) {
const offer = this.offers[i];
const skill = getSkill(offer.id);
const isNew = offer.nextLevel === 1;
title.textContent = `${skill.name}${isNew ? ' (NEU)' : ` · Stufe ${offer.nextLevel}`}`;
desc.textContent = skill.describe(offer.nextLevel);
card.style.display = '';
card.classList.remove('selected');
} else {
card.style.display = 'none';
}
}
this.uiConfirmBtn.disabled = true;
this.uiLevelUp.style.opacity = '1';
this.uiLevelUp.style.pointerEvents = 'auto';
}
private hideLevelUp() {
this.uiLevelUp.style.opacity = '0';
this.uiLevelUp.style.pointerEvents = 'none';
}
private selectOffer(index: number) {
if (this.state !== 'levelup' || index >= this.offers.length) return;
this.selectedOffer = index;
for (let i = 0; i < 3; i++) {
const card = document.getElementById(`offer-${i}`)!;
card.classList.toggle('selected', i === index);
}
this.uiConfirmBtn.disabled = false;
}
private confirmOffer() {
if (this.state !== 'levelup' || this.selectedOffer < 0) return;
const offer = this.offers[this.selectedOffer];
this.selectedOffer = -1;
this.skillSystem.apply(offer.id);
this.player.applySkillSystem(this.skillSystem);
this.syncSkillVisibility();
this.blurFocus();
if (this.pendingPicks > 0) {
this.pendingPicks--;
this.openLevelUp();
} else if (this.skillSystem.hasPendingLevelUp()) {
this.openLevelUp();
} else {
this.hideLevelUp();
this.logState('playing', 'confirmOffer');
this.state = 'playing';
}
}
private syncSkillVisibility() {
const s = this.skillSystem;
this.player.leftHandWeapon.mesh.visible = s.getLevel('fireball') > 0;
this.uiFireballContainer.style.display = s.getLevel('fireball') > 0 ? '' : 'none';
this.uiTeleportContainer.style.display = s.getLevel('teleport') > 0 ? '' : 'none';
this.uiLightningContainer.style.display = s.getLevel('chainlightning') > 0 ? '' : 'none';
}
private spawnEnemy() { private spawnEnemy() {
const x = (Math.random() - 0.5) * 80; const x = (Math.random() - 0.5) * 80;
const z = (Math.random() - 0.5) * 80; const z = (Math.random() - 0.5) * 80;
const enemy = new Enemy(this.player.body.position);
enemy.body.position.set(x, 0.1, z); const weights = this.enemyWeights(this.spawns);
let total = 0;
for (const w of weights.values()) total += w;
let roll = Math.random() * total;
let kind = 'spider';
for (const [k, w] of weights) {
roll -= w;
if (roll <= 0) {
kind = k;
break;
}
}
switch (kind) {
case 'spider': this.spawnEnemyInstance(new Spider(), x, z, 0.1); break;
case 'ghost': this.spawnEnemyInstance(new Ghost(), x, z, 1.0); break;
case 'frog': this.spawnEnemyInstance(new Frog(), x, z, 0.1); break;
case 'bat': this.spawnEnemyInstance(new Bat(), x, z, 1.5); break;
case 'bee': this.spawnEnemyInstance(new BeeSwarm(), x, z, 1.2); break;
case 'slime': this.spawnEnemyInstance(new Slime(), x, z, 0.1); break;
case 'brute': this.spawnEnemyInstance(new Brute(), x, z, 0.15); break;
case 'crab': this.spawnEnemyInstance(new Crab(), x, z, 0.1); break;
case 'turret': this.spawnEnemyInstance(new Turret(), x, z, 0.1); break;
}
}
private enemyWeights(wave: number): Map<string, number> {
const w = new Map<string, number>();
if (wave >= 7) {
w.set('spider', 25).set('ghost', 15).set('frog', 15).set('slime', 10)
.set('bat', 10).set('turret', 10).set('crab', 10).set('bee', 5);
} else if (wave >= 6) {
w.set('spider', 35).set('ghost', 20).set('frog', 15).set('slime', 10)
.set('bat', 10).set('turret', 10);
} else if (wave >= 5) {
w.set('spider', 45).set('ghost', 20).set('frog', 15).set('slime', 10)
.set('bat', 10);
} else if (wave >= 4) {
w.set('spider', 55).set('ghost', 25).set('frog', 20);
} else if (wave >= 3) {
w.set('spider', 75).set('ghost', 25);
} else {
w.set('spider', 100);
}
return w;
}
spawnEnemyInstance(enemy: Enemy, x: number, z: number, y: number) {
enemy.body.position.set(x, y, z);
this.scene.add(enemy.body); this.scene.add(enemy.body);
this.enemies.push(enemy); this.enemies.push(enemy);
} }
spawnEnemyProjectile(from: THREE.Vector3, target: THREE.Vector3, damage: number) {
const projectile = new EnemyProjectile(from, target, damage);
this.scene.add(projectile.body);
this.projectiles.push(projectile);
}
destroyProjectilesInArc(pos: THREE.Vector3, aim: THREE.Vector3, range: number) {
for (let i = this.projectiles.length - 1; i >= 0; i--) {
const p = this.projectiles[i];
if (isInSlashArc(pos, aim, p.body.position, range)) {
p.dispose();
this.projectiles.splice(i, 1);
}
}
}
private spawnCross(position: THREE.Vector3) { private spawnCross(position: THREE.Vector3) {
if (Math.random() > 0.9) { if (Math.random() > 0.9) {
const cross = new Cross(position.clone()); const cross = new Cross(position.clone());
@@ -319,7 +694,7 @@ export class Game {
} }
private updatePlayerMovement(dt: number) { private updatePlayerMovement(dt: number) {
if (this.gameOver) { if (this.state !== 'playing') {
this.player.body.position.y = 0.5; this.player.body.position.y = 0.5;
return; return;
} }
@@ -338,7 +713,7 @@ export class Game {
if (this.keys.has('KeyA') || this.keys.has('ArrowLeft')) moveDir.add(camLeft); if (this.keys.has('KeyA') || this.keys.has('ArrowLeft')) moveDir.add(camLeft);
if (this.keys.has('KeyD') || this.keys.has('ArrowRight')) moveDir.sub(camLeft); if (this.keys.has('KeyD') || this.keys.has('ArrowRight')) moveDir.sub(camLeft);
let speed = 7.5; let speed = this.player.stats.moveSpeed;
let isMoving = false; let isMoving = false;
if (moveDir.length() > 0) { if (moveDir.length() > 0) {
moveDir.normalize(); moveDir.normalize();
@@ -379,45 +754,18 @@ export class Game {
); );
} }
private clampToArena(pos: THREE.Vector3) { clampToArena(pos: THREE.Vector3) {
pos.x = Math.max(-47, Math.min(47, pos.x)); pos.x = Math.max(-47, Math.min(47, pos.x));
pos.z = Math.max(-47, Math.min(47, pos.z)); pos.z = Math.max(-47, Math.min(47, pos.z));
} }
private updateEnemies(dt: number) { private updateEnemies(dt: number) {
for (const enemy of this.enemies) { for (const enemy of this.enemies) {
if (enemy.dead) { if (enemy.dead || this.state !== 'playing') {
enemy.updateAnimation(dt); enemy.updateAnimation(dt);
continue; continue;
} }
enemy.update(dt, this);
const toPlayer = new THREE.Vector3()
.subVectors(this.player.body.position, enemy.body.position);
toPlayer.y = 0;
const dist = toPlayer.length();
if (dist > 0.1) {
const dir = toPlayer.normalize();
enemy.body.lookAt(
enemy.body.position.x + dir.x,
enemy.body.position.y,
enemy.body.position.z + dir.z
);
enemy.playAnim('walk');
let moveDir = dir.multiplyScalar(6);
if (enemy.knockback > 0) {
moveDir = dir.multiplyScalar(-6);
enemy.knockback -= dt;
}
enemy.body.position.x += moveDir.x * dt;
enemy.body.position.z += moveDir.z * dt;
this.clampToArena(enemy.body.position);
} else {
enemy.playAnim('stand');
}
enemy.updateAnimation(dt); enemy.updateAnimation(dt);
} }
} }
@@ -434,8 +782,8 @@ export class Game {
for (const enemy of this.enemies) { for (const enemy of this.enemies) {
if (enemy.dead) continue; if (enemy.dead) continue;
const dist = enemy.body.position.distanceTo(fb.body.position); const dist = enemy.body.position.distanceTo(fb.body.position);
if (dist < 8) { if (dist < fb.explosionRadius) {
enemy.takeDamage(100, this); enemy.takeDamage(fb.damage, this, true, fb.body.position);
} }
} }
} }
@@ -466,15 +814,37 @@ export class Game {
} }
private updateUI() { private updateUI() {
const healthPct = this.player.health / this.player.MAX_HEALTH; const maxHealth = this.player.stats.maxHealth;
const healthPct = this.player.health / maxHealth;
this.uiHealthBar.style.width = `${Math.max(0, healthPct * 250)}px`; this.uiHealthBar.style.width = `${Math.max(0, healthPct * 250)}px`;
this.uiHealthText.textContent = `${Math.ceil(this.player.health)}%`; this.uiHealthText.textContent = `${Math.ceil(this.player.health)}`;
const shieldMax = this.player.stats.shield;
this.uiShieldBarBg.style.display = shieldMax > 0 ? '' : 'none';
this.uiShieldBar.style.display = shieldMax > 0 ? '' : 'none';
this.uiShieldText.style.display = shieldMax > 0 ? '' : 'none';
if (shieldMax > 0) {
const shieldPct = this.player.shieldHp / shieldMax;
this.uiShieldBar.style.width = `${Math.max(0, shieldPct * 250)}px`;
this.uiShieldText.textContent = `${Math.ceil(this.player.shieldHp)}`;
}
this.uiWaveLabel.textContent = `Wave ${this.spawns}`; this.uiWaveLabel.textContent = `Wave ${this.spawns}`;
this.uiKillsLabel.textContent = `Kills: ${this.killed}`; this.uiKillsLabel.textContent = `Kills: ${this.killed}`;
this.uiLevelLabel.textContent = `Level ${this.skillSystem.level}`;
this.uiSeedLabel.textContent = this.seed === null
? ''
: `Seed: ${this.seed}`;
const xpPct = this.skillSystem.xpProgress();
this.uiXpBarFill.style.width = `${Math.min(1, xpPct) * 100}%`;
this.uiXpBarLabel.textContent = this.skillSystem.hasAnyUpgradeLeft()
? `XP: ${Math.floor(this.skillSystem.xp)}/${this.skillSystem.xpNeeded()}`
: 'MAX';
const sword = this.player.rightHandWeapon as Sword; const sword = this.player.rightHandWeapon as Sword;
const staff = this.player.leftHandWeapon as Staff; const staff = this.player.leftHandWeapon as Staff;
const lightning = this.player.skillLightning;
const fbPct = Math.max(0, (staff.COOLDOWN1_TIME - staff.cooldown1) / staff.COOLDOWN1_TIME); const fbPct = Math.max(0, (staff.COOLDOWN1_TIME - staff.cooldown1) / staff.COOLDOWN1_TIME);
this.uiFireballCD.style.width = `${fbPct * 100}%`; this.uiFireballCD.style.width = `${fbPct * 100}%`;
@@ -485,15 +855,15 @@ export class Game {
const tpPct = Math.max(0, (staff.COOLDOWN2_TIME - staff.cooldown2) / staff.COOLDOWN2_TIME); const tpPct = Math.max(0, (staff.COOLDOWN2_TIME - staff.cooldown2) / staff.COOLDOWN2_TIME);
this.uiTeleportCD.style.width = `${tpPct * 100}%`; this.uiTeleportCD.style.width = `${tpPct * 100}%`;
const lnPct = Math.max(0, (lightning.COOLDOWN1_TIME - lightning.cooldown1) / lightning.COOLDOWN1_TIME);
this.uiLightningCD.style.width = `${lnPct * 100}%`;
const wavePct = this.spawnTimer / SPAWN_TIME; const wavePct = this.spawnTimer / SPAWN_TIME;
this.uiWaveBarFill.style.width = `${wavePct * 100}%`; this.uiWaveBarFill.style.width = `${wavePct * 100}%`;
this.uiWaveBarLabel.textContent = `Next Wave: ${Math.ceil(SPAWN_TIME - this.spawnTimer)}s`; this.uiWaveBarLabel.textContent = `Next Wave: ${Math.ceil(SPAWN_TIME - this.spawnTimer)}s`;
if (this.player.health <= 0 && !this.gameOver) { if (this.player.health <= 0 && this.state === 'playing') {
this.gameOver = true; this.gameOver();
this.player.die();
this.uiGameOver.style.opacity = '1';
playSound('gameover', 0.8);
} }
} }
@@ -508,28 +878,61 @@ export class Game {
this.updateFireballs(dt); this.updateFireballs(dt);
this.updateHitzones(); this.updateHitzones();
// Update slash effects
for (let i = this.slashEffects.length - 1; i >= 0; i--) {
if (!this.slashEffects[i].update(dt)) {
this.slashEffects.splice(i, 1);
}
}
// Update animations // Update animations
this.player.updateAnimations(dt); this.player.updateAnimations(dt);
this.player.rightHandWeapon.updateAnimation(dt); this.player.rightHandWeapon.updateAnimation(dt);
// Spawn enemies in waves // Spawn enemies in waves
if (!this.gameOver) { if (this.state === 'playing') {
this.spawnTimer += dt; this.spawnTimer += dt;
if (this.spawnTimer >= SPAWN_TIME) { if (this.spawnTimer >= SPAWN_TIME) {
this.spawns++; this.spawns++;
for (let i = 0; i < this.spawns; i++) { if (this.spawns % 5 === 0) {
this.spawnEnemy(); const x = (Math.random() - 0.5) * 80;
const z = (Math.random() - 0.5) * 80;
this.spawnEnemyInstance(new Boss(), x, z, 0.15);
playSound('spawn', 0.8);
} else {
for (let i = 0; i < this.spawns; i++) {
this.spawnEnemy();
}
} }
this.spawnTimer = 0; this.spawnTimer = 0;
} }
} }
// Check close encounters (spider touching player) // Check close encounters (enemy touching player)
for (const enemy of this.enemies) { if (this.state === 'playing') {
if (enemy.dead) continue; const thorns = this.player.stats.thornDamage;
const dist = enemy.body.position.distanceTo(this.player.body.position); for (const enemy of this.enemies) {
if (dist < 2) { if (enemy.dead) continue;
this.player.health -= 25 * dt; const dist = enemy.body.position.distanceTo(this.player.body.position);
if (dist < enemy.contactRadius) {
this.player.damage(enemy.contactDps * dt);
if (thorns > 0) {
enemy.takeDamage(thorns * dt, this, false);
}
}
}
this.player.updatePassives(dt);
}
// Enemy projectiles
if (this.state === 'playing') {
for (let i = this.projectiles.length - 1; i >= 0; i--) {
const p = this.projectiles[i];
const alive = p.update(dt, this);
if (!alive || p.dead) {
p.dispose();
this.projectiles.splice(i, 1);
}
} }
} }
@@ -537,17 +940,20 @@ export class Game {
for (let i = this.crosses.length - 1; i >= 0; i--) { for (let i = this.crosses.length - 1; i >= 0; i--) {
const cross = this.crosses[i]; const cross = this.crosses[i];
cross.update(dt); cross.update(dt);
const dist = cross.body.position.distanceTo(this.player.body.position); if (this.state === 'playing') {
if (dist < 2) { const dist = cross.body.position.distanceTo(this.player.body.position);
this.player.heal(25); if (dist < 2) {
this.scene.remove(cross.body); this.player.heal(25);
this.crosses.splice(i, 1); this.scene.remove(cross.body);
this.crosses.splice(i, 1);
}
} }
} }
// Update cooldowns // Update cooldowns
this.player.rightHandWeapon.reduceCooldowns(dt); this.player.rightHandWeapon.reduceCooldowns(dt);
this.player.leftHandWeapon.reduceCooldowns(dt); this.player.leftHandWeapon.reduceCooldowns(dt);
this.player.skillLightning.reduceCooldowns(dt);
// Camera follows player // Camera follows player
this.camera.position.set( this.camera.position.set(
@@ -580,6 +986,23 @@ export class Game {
removeEnemy(enemy: Enemy) { removeEnemy(enemy: Enemy) {
enemy.dead = true; enemy.dead = true;
this.killed++; this.killed++;
this.spawnCross(enemy.body.position); if (enemy.guaranteedDrop) {
const cross = new Cross(enemy.body.position.clone());
this.scene.add(cross.body);
this.crosses.push(cross);
} else {
this.spawnCross(enemy.body.position);
}
if (this.state === 'playing' || this.state === 'levelup') {
const leveled = this.skillSystem.addXp(enemy.xp);
if (leveled) {
if (this.state === 'playing') {
this.openLevelUp();
} else {
this.pendingPicks++;
}
}
}
} }
} }
+246
View File
@@ -0,0 +1,246 @@
import * as THREE from 'three';
import type { Game } from './Game';
import { Enemy } from './Enemy';
const FLOAT_HEIGHT = 1.0;
const BOB_AMPLITUDE = 0.3;
const BOB_SPEED = 2.4;
const VISIBLE_TIME = 2.5;
const FADE_TIME = 0.35;
const HIDDEN_MIN = 0.6;
const HIDDEN_MAX = 1.5;
type VisState = 'visible' | 'fadingOut' | 'hidden' | 'fadingIn';
export class Ghost extends Enemy {
private model: THREE.Group;
private shadow: THREE.Mesh;
private shadowMat: THREE.MeshBasicMaterial;
private whiteMat: THREE.MeshToonMaterial;
private darkMat: THREE.MeshToonMaterial;
private blushMat: THREE.MeshToonMaterial;
private shadowCasters: THREE.Mesh[] = [];
private arms: THREE.Mesh[] = [];
private bobTime = Math.random() * Math.PI * 2;
private visState: VisState = 'visible';
private visTimer = VISIBLE_TIME + Math.random();
private fadeDying = false;
private fadeDieTime = 0;
constructor() {
super();
this.health = 40;
this.speed = 7.5;
this.xp = 15;
this.model = new THREE.Group();
this.body.add(this.model);
this.whiteMat = new THREE.MeshToonMaterial({
color: 0xf8f8ff,
emissive: 0x2a2a3a,
transparent: true,
opacity: 0.96,
});
this.darkMat = new THREE.MeshToonMaterial({
color: 0x1c1c2e,
transparent: true,
opacity: 1,
});
this.blushMat = new THREE.MeshToonMaterial({
color: 0xffaacc,
transparent: true,
opacity: 0.85,
});
// Meringue body: stacked blobs tapering to a swirl peak
const base = new THREE.Mesh(new THREE.SphereGeometry(0.62, 24, 18), this.whiteMat);
base.scale.set(1.05, 0.8, 1.05);
base.position.y = 0.3;
this.addCaster(base);
const mid = new THREE.Mesh(new THREE.SphereGeometry(0.45, 20, 16), this.whiteMat);
mid.position.y = 0.78;
this.addCaster(mid);
const tip = new THREE.Mesh(new THREE.SphereGeometry(0.27, 16, 12), this.whiteMat);
tip.position.y = 1.18;
this.addCaster(tip);
// Swirl peak (little curled top like a meringue)
const swirl = new THREE.Mesh(new THREE.TorusGeometry(0.2, 0.075, 8, 20), this.whiteMat);
swirl.position.y = 1.3;
swirl.rotation.x = -0.6;
this.addCaster(swirl);
const curl = new THREE.Mesh(new THREE.SphereGeometry(0.1, 10, 8), this.whiteMat);
curl.position.set(0, 1.48, -0.02);
this.addCaster(curl);
// Stubby little arms
const armGeom = new THREE.CapsuleGeometry(0.09, 0.16, 4, 10);
for (const side of [-1, 1]) {
const arm = new THREE.Mesh(armGeom, this.whiteMat);
arm.position.set(side * 0.72, 0.72, 0.12);
arm.rotation.z = side * 0.55;
arm.rotation.x = -0.35;
this.addCaster(arm);
this.arms.push(arm);
}
// Face
const eyeGeom = new THREE.SphereGeometry(0.09, 12, 10);
for (const side of [-1, 1]) {
const eye = new THREE.Mesh(eyeGeom, this.darkMat);
eye.position.set(side * 0.2, 0.85, 0.44);
this.model.add(eye);
}
const mouth = new THREE.Mesh(
new THREE.TorusGeometry(0.09, 0.025, 6, 14, Math.PI),
this.darkMat
);
mouth.position.set(0, 0.66, 0.48);
mouth.rotation.z = Math.PI;
this.model.add(mouth);
for (const side of [-1, 1]) {
const blush = new THREE.Mesh(new THREE.SphereGeometry(0.075, 10, 8), this.blushMat);
blush.position.set(side * 0.34, 0.68, 0.38);
this.model.add(blush);
}
// Blob shadow on the ground (floating ghost, no real shadow casting)
this.shadowMat = new THREE.MeshBasicMaterial({
color: 0x000000,
transparent: true,
opacity: 0.25,
});
this.shadow = new THREE.Mesh(new THREE.CircleGeometry(0.55, 20), this.shadowMat);
this.shadow.rotation.x = -Math.PI / 2;
this.shadow.renderOrder = 998;
this.body.add(this.shadow);
}
private addCaster(mesh: THREE.Mesh) {
mesh.castShadow = true;
this.model.add(mesh);
this.shadowCasters.push(mesh);
}
playAnim() {
// No rigged animations, visuals are procedural
}
updateAnimation(dt: number) {
if (!this.fadeDying) return;
this.fadeDieTime += dt;
const t = Math.min(this.fadeDieTime / 0.9, 1);
const scale = Math.max(0.05, 1 - t * 0.95);
this.model.scale.set(scale, scale, scale);
this.body.position.y = Math.max(-1, this.body.position.y - dt * 2.2);
this.setOpacity(1 - t);
}
update(dt: number, game: Game) {
this.bobTime += dt * BOB_SPEED;
this.body.position.y = FLOAT_HEIGHT + Math.sin(this.bobTime) * BOB_AMPLITUDE;
// Shadow stays on the ground
this.shadow.position.y = 0.06 - this.body.position.y;
// Gentle squash & stretch
const squash = 1 + Math.sin(this.bobTime * 2) * 0.05;
this.model.scale.y = squash;
this.model.scale.x = 1 + (1 - squash) * 0.5;
this.model.scale.z = this.model.scale.x;
// Arm wiggle
for (let i = 0; i < this.arms.length; i++) {
const side = i === 0 ? -1 : 1;
this.arms[i].rotation.z = side * 0.55 + Math.sin(this.bobTime + i * Math.PI) * 0.2;
}
// Invisibility cycle
this.visTimer -= dt;
switch (this.visState) {
case 'visible':
if (this.visTimer <= 0) {
this.visState = 'fadingOut';
this.visTimer = FADE_TIME;
}
break;
case 'fadingOut': {
const op = Math.max(0, this.visTimer / FADE_TIME);
this.setOpacity(op);
if (this.visTimer <= 0) {
this.visState = 'hidden';
this.visTimer = HIDDEN_MIN + Math.random() * (HIDDEN_MAX - HIDDEN_MIN);
}
break;
}
case 'hidden':
this.setOpacity(0);
if (this.visTimer <= 0) {
this.visState = 'fadingIn';
this.visTimer = FADE_TIME;
}
break;
case 'fadingIn': {
const op = 1 - Math.max(0, this.visTimer / FADE_TIME);
this.setOpacity(op);
if (this.visTimer <= 0) {
this.visState = 'visible';
this.visTimer = VISIBLE_TIME + Math.random() * 0.5;
}
break;
}
}
// Chase the player (stays hittable and dangerous even while invisible)
const toPlayer = new THREE.Vector3()
.subVectors(game.player.body.position, this.body.position);
toPlayer.y = 0;
const dist = toPlayer.length();
if (dist > 0.1) {
const dir = toPlayer.normalize();
this.body.lookAt(
this.body.position.x + dir.x,
this.body.position.y,
this.body.position.z + dir.z
);
let moveDir = dir.multiplyScalar(this.speed);
if (this.knockback > 0) {
moveDir = dir.multiplyScalar(-this.speed);
this.knockback -= dt;
}
this.body.position.x += moveDir.x * dt;
this.body.position.z += moveDir.z * dt;
game.clampToArena(this.body.position);
}
}
private setOpacity(op: number) {
this.whiteMat.opacity = 0.96 * op;
this.darkMat.opacity = op;
this.blushMat.opacity = 0.85 * op;
this.shadowMat.opacity = 0.25 * op;
const casts = op > 0.4;
for (const mesh of this.shadowCasters) mesh.castShadow = casts;
}
protected onDeath(_game: Game) {
this.fadeDying = true;
}
dispose() {
super.dispose();
this.whiteMat.dispose();
this.darkMat.dispose();
this.blushMat.dispose();
this.shadowMat.dispose();
this.shadow.geometry.dispose();
}
}
+93 -17
View File
@@ -1,19 +1,46 @@
import * as THREE from 'three'; import * as THREE from 'three';
import { Sword } from './Sword'; import { Sword } from './Sword';
import { Staff } from './Staff'; import { Staff } from './Staff';
import { ChainLightning } from './ChainLightning';
import { loadGLB } from './MeshLoader'; import { loadGLB } from './MeshLoader';
import type { Game } from './Game'; import type { Game } from './Game';
import type { SkillSystem } from './Skills';
export interface PlayerStats {
maxHealth: number;
regen: number;
lifesteal: number;
shield: number;
shieldRechargeDelay: number;
moveSpeed: number;
swordDamage: number;
swordRange: number;
thornDamage: number;
}
export class Player { export class Player {
body: THREE.Group; body: THREE.Group;
leftHandWeapon: Staff; leftHandWeapon: Staff;
rightHandWeapon: Sword; rightHandWeapon: Sword;
skillLightning: ChainLightning;
velocity = new THREE.Vector3(); velocity = new THREE.Vector3();
health = 100; health = 100;
MAX_HEALTH = 100;
game!: Game; game!: Game;
jumpVelocity = 0; jumpVelocity = 0;
onGround = true; onGround = true;
shieldHp = 0;
shieldTimer = 0;
stats: PlayerStats = {
maxHealth: 100,
regen: 0,
lifesteal: 0,
shield: 0,
shieldRechargeDelay: 8,
moveSpeed: 7.5,
swordDamage: 50,
swordRange: 2.2,
thornDamage: 0,
};
mixer!: THREE.AnimationMixer; mixer!: THREE.AnimationMixer;
private clips: Map<string, THREE.AnimationAction> = new Map(); private clips: Map<string, THREE.AnimationAction> = new Map();
private currentAnim = ''; private currentAnim = '';
@@ -64,6 +91,8 @@ export class Player {
// Weapons // Weapons
this.leftHandWeapon = new Staff(); this.leftHandWeapon = new Staff();
this.rightHandWeapon = new Sword(); this.rightHandWeapon = new Sword();
this.skillLightning = new ChainLightning();
this.leftHandWeapon.mesh.visible = false;
this.body.add(this.leftHandWeapon.mesh); this.body.add(this.leftHandWeapon.mesh);
this.body.add(this.rightHandWeapon.mesh); this.body.add(this.rightHandWeapon.mesh);
} }
@@ -149,27 +178,74 @@ export class Player {
} }
} }
switchHands() { heal(amount: number) {
const temp = this.leftHandWeapon; this.health = Math.min(this.health + amount, this.stats.maxHealth);
this.leftHandWeapon = this.rightHandWeapon as unknown as Staff;
this.rightHandWeapon = temp as unknown as Sword;
const leftPos = this.leftHandWeapon.mesh.position.clone();
const rightPos = this.rightHandWeapon.mesh.position.clone();
this.leftHandWeapon.mesh.position.copy(rightPos);
this.rightHandWeapon.mesh.position.copy(leftPos);
const leftRot = this.leftHandWeapon.mesh.rotation.clone();
const rightRot = this.rightHandWeapon.mesh.rotation.clone();
this.leftHandWeapon.mesh.rotation.copy(rightRot);
this.rightHandWeapon.mesh.rotation.copy(leftRot);
} }
heal(amount: number) { damage(amount: number) {
this.health = Math.min(this.health + amount, this.MAX_HEALTH); if (this.shieldHp > 0) {
const absorbed = Math.min(this.shieldHp, amount);
this.shieldHp -= absorbed;
amount -= absorbed;
this.shieldTimer = this.stats.shieldRechargeDelay;
}
this.health -= amount;
}
updatePassives(dt: number) {
if (this.stats.regen > 0) {
this.heal(this.stats.regen * dt);
}
if (this.stats.shield > 0) {
if (this.shieldHp < this.stats.shield) {
this.shieldTimer -= dt;
if (this.shieldTimer <= 0) {
this.shieldHp = this.stats.shield;
}
} else {
this.shieldTimer = 0;
}
}
}
applySkillSystem(skills: SkillSystem) {
const oldMax = this.stats.maxHealth;
const l = (id: string) => skills.getLevel(id);
this.stats = {
maxHealth: 100 + 25 * l('maxHp'),
regen: 1.5 * l('regen'),
lifesteal: 0.05 * l('lifesteal'),
shield: 25 * l('shield'),
shieldRechargeDelay: 8,
moveSpeed: 7.5 + l('speed'),
swordDamage: 50 + 25 * l('swordDamage'),
swordRange: 2.2 + 0.6 * l('swordRange'),
thornDamage: 15 * l('thorns'),
};
if (this.stats.maxHealth > oldMax) {
this.health = this.stats.maxHealth;
} else {
this.health = Math.min(this.health, this.stats.maxHealth);
}
if (this.stats.shield > 0 && this.shieldHp <= 0) {
this.shieldHp = this.stats.shield;
}
} }
die() { die() {
this.playAnimation('die', false); this.playAnimation('die', false);
} }
reset() {
this.health = 100;
this.shieldHp = 0;
this.shieldTimer = 0;
this.body.position.set(0, 0.5, 0);
this.velocity.set(0, 0, 0);
this.jumpVelocity = 0;
this.onGround = true;
this.playAnimation('stand');
}
} }
+212
View File
@@ -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;
}
}
+110
View File
@@ -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
);
}
}
}
}
+138
View File
@@ -0,0 +1,138 @@
import * as THREE from 'three';
import type { Game } from './Game';
import { Enemy } from './Enemy';
import { playLoopingSound } from './SoundManager';
import { loadGLB } from './MeshLoader';
import type { GLTF } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { clone as cloneSkeleton } from 'three/examples/jsm/utils/SkeletonUtils.js';
let spiderGLTF: GLTF | null = null;
const ANIMATIONS_ENABLED = true;
export async function initSpiderModel() {
try {
spiderGLTF = await loadGLB('spider');
} catch {
console.warn('Failed to load spider model');
}
}
export class Spider extends Enemy {
constructor() {
super();
this.stopWalkSound = playLoopingSound('spiderwalking', 0.3);
if (spiderGLTF) {
const model = cloneSkeleton(spiderGLTF.scene);
// Original game scaled the spider model by 0.5 (WalkingEnemy.setupModel)
model.scale.set(0.5, 0.5, 0.5);
const spiderTexture = new THREE.TextureLoader().load('./textures/spider_animation2.png');
spiderTexture.flipY = false;
spiderTexture.colorSpace = THREE.SRGBColorSpace;
model.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.castShadow = true;
child.material = new THREE.MeshToonMaterial({ map: spiderTexture });
}
});
this.body.add(model);
if (spiderGLTF.animations.length > 0 && ANIMATIONS_ENABLED) {
this.mixer = new THREE.AnimationMixer(model);
for (const clip of spiderGLTF.animations) {
const action = this.mixer.clipAction(clip);
this.clips.set(clip.name, action);
}
this.playAnim('stand');
}
} else {
// Fallback
const abdomenGeom = new THREE.SphereGeometry(0.5, 8, 8);
const bodyMat = new THREE.MeshToonMaterial({ color: 0x333333 });
const abdomen = new THREE.Mesh(abdomenGeom, bodyMat);
abdomen.scale.set(1, 0.6, 1.3);
abdomen.position.y = 0.6;
abdomen.castShadow = true;
this.body.add(abdomen);
const cephalothoraxGeom = new THREE.SphereGeometry(0.3, 8, 8);
const cephalothorax = new THREE.Mesh(cephalothoraxGeom, bodyMat);
cephalothorax.position.y = 0.6;
cephalothorax.position.z = 0.5;
cephalothorax.castShadow = true;
this.body.add(cephalothorax);
const eyeGeom = new THREE.SphereGeometry(0.06, 6, 6);
const eyeMat = new THREE.MeshBasicMaterial({ color: 0xff0000 });
for (const sign of [-1, 1]) {
const eye = new THREE.Mesh(eyeGeom, eyeMat);
eye.position.set(sign * 0.12, 0.85, 0.7);
this.body.add(eye);
}
const legGeom = new THREE.CylinderGeometry(0.04, 0.04, 0.8, 4);
for (let i = 0; i < 8; i++) {
const leg = new THREE.Mesh(legGeom, new THREE.MeshToonMaterial({ color: 0x222222 }));
const angle = (i / 8) * Math.PI * 2;
const side = i < 4 ? 1 : -1;
leg.position.set(Math.cos(angle) * 0.35, 0.3, Math.sin(angle) * 0.35);
leg.rotation.z = side * 0.6;
leg.rotation.x = angle;
leg.castShadow = true;
this.body.add(leg);
}
}
}
playAnim(name: string, loop = true, speed = 1) {
if (!this.clips.has(name) || this.currentAnim === name) return;
// Stop the previous animation so it doesn't blend with the new one
if (this.currentAnim) {
const prev = this.clips.get(this.currentAnim);
if (prev) prev.stop();
}
this.currentAnim = name;
const action = this.clips.get(name)!;
action.reset();
action.setLoop(loop ? THREE.LoopRepeat : THREE.LoopOnce, loop ? Infinity : 1);
action.clampWhenFinished = !loop;
action.setEffectiveTimeScale(speed);
action.play();
}
updateAnimation(dt: number) {
if (this.mixer) this.mixer.update(dt);
}
update(dt: number, game: Game) {
const toPlayer = new THREE.Vector3()
.subVectors(game.player.body.position, this.body.position);
toPlayer.y = 0;
const dist = toPlayer.length();
if (dist > 0.1) {
const dir = toPlayer.normalize();
this.body.lookAt(
this.body.position.x + dir.x,
this.body.position.y,
this.body.position.z + dir.z
);
this.playAnim('walk');
let moveDir = dir.multiplyScalar(this.speed);
if (this.knockback > 0) {
moveDir = dir.multiplyScalar(-this.speed);
this.knockback -= dt;
}
this.body.position.x += moveDir.x * dt;
this.body.position.z += moveDir.z * dt;
game.clampToArena(this.body.position);
} else {
this.playAnim('stand');
}
}
protected onDeath(_game: Game) {
this.playAnim('die', false);
}
}
+11
View File
@@ -4,6 +4,7 @@ import { Player } from './Player';
import { Fireball } from './Fireball'; import { Fireball } from './Fireball';
import { playSound } from './SoundManager'; import { playSound } from './SoundManager';
import { loadGLB } from './MeshLoader'; import { loadGLB } from './MeshLoader';
import { fireballDamage, fireballCooldown, fireballRadius, teleportCooldown } from './Skills';
export class Staff extends Weapon { export class Staff extends Weapon {
constructor() { constructor() {
@@ -52,7 +53,11 @@ export class Staff extends Weapon {
} }
skill1(groundPoint: THREE.Vector3, player: Player): void { skill1(groundPoint: THREE.Vector3, player: Player): void {
const level = player.game.skillSystem.getLevel('fireball');
if (level <= 0) return;
if (this.cooldown1 > 0) return; if (this.cooldown1 > 0) return;
this.COOLDOWN1_TIME = fireballCooldown(level);
this.cooldown1 = this.COOLDOWN1_TIME; this.cooldown1 = this.COOLDOWN1_TIME;
// Spawn at staff height, fly toward the cursor point // Spawn at staff height, fly toward the cursor point
@@ -66,13 +71,19 @@ export class Staff extends Weapon {
dir.setY(0); dir.setY(0);
const fb = new Fireball(spawnPos, dir); const fb = new Fireball(spawnPos, dir);
fb.damage = fireballDamage(level);
fb.explosionRadius = fireballRadius(level);
player.game.scene.add(fb.body); player.game.scene.add(fb.body);
player.game.fireballs.push(fb); player.game.fireballs.push(fb);
playSound('fireball', 0.5); playSound('fireball', 0.5);
} }
skill2(groundPoint: THREE.Vector3, player: Player): void { skill2(groundPoint: THREE.Vector3, player: Player): void {
const level = player.game.skillSystem.getLevel('teleport');
if (level <= 0) return;
if (this.cooldown2 > 0) return; if (this.cooldown2 > 0) return;
this.COOLDOWN2_TIME = teleportCooldown(level);
this.cooldown2 = this.COOLDOWN2_TIME; this.cooldown2 = this.COOLDOWN2_TIME;
const newPos = groundPoint.clone(); const newPos = groundPoint.clone();
+24 -10
View File
@@ -3,6 +3,7 @@ import { Weapon } from './Weapon';
import { Player } from './Player'; import { Player } from './Player';
import { loadGLB } from './MeshLoader'; import { loadGLB } from './MeshLoader';
import { playSound } from './SoundManager'; import { playSound } from './SoundManager';
import { SlashEffect, isInSlashArc } from './SwordTrail';
export class Sword extends Weapon { export class Sword extends Weapon {
private mixer: THREE.AnimationMixer | null = null; private mixer: THREE.AnimationMixer | null = null;
@@ -97,20 +98,33 @@ export class Sword extends Weapon {
const { game } = player; const { game } = player;
const range = player.stats.swordRange;
const damage = player.stats.swordDamage;
// Aim direction: where the blob actually faces (toward the mouse point)
const aim = new THREE.Vector3()
.subVectors(game.groundPoint, player.body.position);
aim.y = 0;
if (aim.lengthSq() < 0.001) {
aim.set(0, 0, -1).applyQuaternion(player.body.quaternion);
aim.y = 0;
}
aim.normalize();
// Slash trail feedback
const slash = new SlashEffect(player.body.position, aim, game.scene, range);
game.slashEffects.push(slash);
for (const enemy of game.enemies) { for (const enemy of game.enemies) {
if (enemy.dead) continue; if (enemy.dead) continue;
const dist = enemy.body.position.distanceTo(player.body.position); if (!enemy.canBeSlashHit()) continue;
if (dist < 3) { if (isInSlashArc(player.body.position, aim, enemy.body.position, range)) {
const toEnemy = new THREE.Vector3() enemy.takeDamage(damage, game, true, player.body.position);
.subVectors(enemy.body.position, player.body.position).normalize();
const playerFwd = new THREE.Vector3(0, 0, 1);
playerFwd.applyQuaternion(player.body.quaternion);
const dot = playerFwd.dot(toEnemy);
if (dot > 0.3) {
enemy.takeDamage(50, game);
}
} }
} }
// Destroy enemy projectiles caught in the slash
game.destroyProjectilesInArc(player.body.position, aim, range);
} }
skill2(): void { skill2(): void {
+172
View File
@@ -0,0 +1,172 @@
import * as THREE from 'three';
const DURATION = 0.3;
const SPARK_COUNT = 20;
const SLASH_HALF_ANGLE = 1.22;
// Enemies are ~0.5-0.7 wide; allow their body to overlap the arc edge.
const HIT_MARGIN = 0.6;
function arcShape(r0: number, r1: number): THREE.Shape {
const shape = new THREE.Shape();
shape.absarc(0, 0, r1, -SLASH_HALF_ANGLE, SLASH_HALF_ANGLE, false);
shape.absarc(0, 0, r0, SLASH_HALF_ANGLE, -SLASH_HALF_ANGLE, true);
shape.closePath();
return shape;
}
// ShapeGeometry builds the arc around +X in the XY plane; rotate it so it
// lies flat on the ground and is centered on +Z (the player's forward).
function arcGeometry(r0: number, r1: number): THREE.ShapeGeometry {
const geom = new THREE.ShapeGeometry(arcShape(r0, r1), 32);
geom.rotateX(-Math.PI / 2);
geom.rotateY(-Math.PI / 2);
return geom;
}
export function isInSlashArc(
playerPos: THREE.Vector3,
forward: THREE.Vector3,
targetPos: THREE.Vector3,
radius: number
): boolean {
const fwd = new THREE.Vector3(forward.x, 0, forward.z).normalize();
const toTarget = new THREE.Vector3().subVectors(targetPos, playerPos);
toTarget.y = 0;
const dist = toTarget.length();
if (dist > radius + HIT_MARGIN) return false;
if (dist < 0.01) return true;
return fwd.dot(toTarget.normalize()) > Math.cos(SLASH_HALF_ANGLE);
}
export class SlashEffect {
private group: THREE.Group;
private outerMat: THREE.MeshBasicMaterial;
private innerMat: THREE.MeshBasicMaterial;
private sparkMat: THREE.PointsMaterial;
private sparks: THREE.Points;
private sparkVelocities: Float32Array;
private sparkLives: Float32Array;
private sparkMaxLife = 0.25;
private sparkPositions: Float32Array;
private t = 0;
private done = false;
constructor(
position: THREE.Vector3,
forward: THREE.Vector3,
scene: THREE.Scene,
radius: number
) {
const yaw = Math.atan2(forward.x, forward.z);
this.group = new THREE.Group();
this.group.position.copy(position);
this.group.position.y = 0.12;
this.group.rotation.y = yaw;
scene.add(this.group);
// Outer soft arc (marks the full hit area)
this.outerMat = new THREE.MeshBasicMaterial({
color: 0xffffff,
transparent: true,
opacity: 0.5,
blending: THREE.AdditiveBlending,
depthWrite: false,
side: THREE.DoubleSide,
});
const outer = new THREE.Mesh(
arcGeometry(0.5, radius),
this.outerMat
);
this.group.add(outer);
// Inner hot arc
this.innerMat = new THREE.MeshBasicMaterial({
color: 0xffffcc,
transparent: true,
opacity: 0.85,
blending: THREE.AdditiveBlending,
depthWrite: false,
side: THREE.DoubleSide,
});
const inner = new THREE.Mesh(
arcGeometry(0.7, radius * 0.8),
this.innerMat
);
this.group.add(inner);
// Sparks along the arc (centered on +Z, the forward direction)
this.sparkPositions = new Float32Array(SPARK_COUNT * 3);
this.sparkVelocities = new Float32Array(SPARK_COUNT * 3);
this.sparkLives = new Float32Array(SPARK_COUNT);
for (let i = 0; i < SPARK_COUNT; i++) {
const angle = -SLASH_HALF_ANGLE
+ (i / (SPARK_COUNT - 1)) * SLASH_HALF_ANGLE * 2;
const sparkRadius = radius * 0.55 + Math.random() * radius * 0.45;
this.sparkPositions[i * 3] = Math.sin(angle) * sparkRadius;
this.sparkPositions[i * 3 + 1] = 0.2 + Math.random() * 0.3;
this.sparkPositions[i * 3 + 2] = Math.cos(angle) * sparkRadius;
this.sparkVelocities[i * 3] = Math.sin(angle) * 2.5;
this.sparkVelocities[i * 3 + 1] = 2 + Math.random() * 3;
this.sparkVelocities[i * 3 + 2] = Math.cos(angle) * 2.5;
this.sparkLives[i] = this.sparkMaxLife * (0.5 + Math.random());
}
const sparkGeom = new THREE.BufferGeometry();
sparkGeom.setAttribute(
'position',
new THREE.BufferAttribute(this.sparkPositions, 3)
);
this.sparkMat = new THREE.PointsMaterial({
color: 0xffffff,
size: 0.12,
blending: THREE.AdditiveBlending,
depthWrite: false,
transparent: true,
});
this.sparks = new THREE.Points(sparkGeom, this.sparkMat);
this.group.add(this.sparks);
}
update(dt: number): boolean {
if (this.done) return false;
this.t += dt;
const progress = Math.min(this.t / DURATION, 1);
const scale = 1 + progress * 0.3;
this.group.scale.set(scale, 1, scale);
this.outerMat.opacity = 0.5 * Math.pow(1 - progress, 1.5);
this.innerMat.opacity = 0.85 * Math.pow(1 - progress, 1.5);
this.sparkMat.opacity = Math.pow(1 - progress, 1.5);
for (let i = 0; i < SPARK_COUNT; i++) {
if (this.sparkLives[i] <= 0) continue;
this.sparkLives[i] -= dt;
this.sparkPositions[i * 3] += this.sparkVelocities[i * 3] * dt;
this.sparkPositions[i * 3 + 1] += this.sparkVelocities[i * 3 + 1] * dt;
this.sparkPositions[i * 3 + 2] += this.sparkVelocities[i * 3 + 2] * dt;
this.sparkVelocities[i * 3 + 1] -= 6 * dt;
}
const attr = this.sparks.geometry
.getAttribute('position') as THREE.BufferAttribute;
attr.needsUpdate = true;
if (progress >= 1) {
this.dispose();
return false;
}
return true;
}
dispose() {
if (this.done) return;
this.done = true;
this.group.removeFromParent();
for (const child of [...this.group.children]) {
if (child instanceof THREE.Mesh || child instanceof THREE.Points) {
child.geometry.dispose();
(child.material as THREE.Material).dispose();
}
}
}
}
+96
View File
@@ -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();
}
}
+3
View File
@@ -9,5 +9,8 @@ export default defineConfig({
server: { server: {
host: true, host: true,
port: 3000, port: 3000,
watch: {
ignored: ['**/dist/**'],
},
}, },
}); });