Add aura, boomerang, trap and gas cloud skills with hotbar and ESC pause

This commit is contained in:
2026-08-19 09:46:22 +02:00
parent fd57ca9740
commit 64d473de6d
10 changed files with 843 additions and 15 deletions
+35 -7
View File
@@ -76,6 +76,8 @@
#sword-cooldown { bottom: 48px; } #sword-cooldown { bottom: 48px; }
#teleport-cooldown { bottom: 24px; } #teleport-cooldown { bottom: 24px; }
#lightning-cooldown { bottom: 96px; } #lightning-cooldown { bottom: 96px; }
#trap-cooldown { bottom: 120px; }
#gas-cooldown { bottom: 144px; }
#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;
} }
@@ -99,7 +101,8 @@
#game-over-panel { #game-over-panel {
position: absolute; top: 62%; left: 50%; transform: translate(-50%, -50%); position: absolute; top: 62%; left: 50%; transform: translate(-50%, -50%);
display: flex; flex-direction: column; align-items: center; gap: 12px; display: flex; flex-direction: column; align-items: center; gap: 12px;
pointer-events: none; opacity: 0; transition: opacity 0.8s ease-in 1.5s; pointer-events: none; opacity: 0; visibility: hidden;
transition: opacity 0.8s ease-in 1.5s;
font-family: monospace; font-family: monospace;
} }
#game-over-stats { #game-over-stats {
@@ -180,7 +183,16 @@
position: absolute; top: 0; left: 0; width: 100%; height: 100%; position: absolute; top: 0; left: 0; width: 100%; height: 100%;
display: flex; flex-direction: column; align-items: center; justify-content: center; 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; gap: 24px; background: rgba(0,0,0,0.7); opacity: 0; pointer-events: none;
transition: opacity 0.3s ease; font-family: monospace; visibility: hidden; transition: opacity 0.3s ease; font-family: monospace;
}
#pause-overlay {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 18px; background: rgba(0,0,0,0.6); opacity: 0; pointer-events: none;
visibility: hidden; transition: opacity 0.2s ease; font-family: monospace;
}
#pause-overlay h2 {
color: #fff; font-size: 48px; text-shadow: 3px 3px 6px #000;
} }
#level-up h2 { #level-up h2 {
color: #ffcc00; font-size: 44px; text-shadow: 3px 3px 6px #000; color: #ffcc00; font-size: 44px; text-shadow: 3px 3px 6px #000;
@@ -259,6 +271,16 @@
<div class="cooldown-fill"></div> <div class="cooldown-fill"></div>
<div class="cooldown-label">Chain Lightning</div> <div class="cooldown-label">Chain Lightning</div>
</div> </div>
<div id="trap-cooldown" class="cooldown-container" style="display: none">
<div class="cooldown-bg"></div>
<div class="cooldown-fill"></div>
<div class="cooldown-label">Falle</div>
</div>
<div id="gas-cooldown" class="cooldown-container" style="display: none">
<div class="cooldown-bg"></div>
<div class="cooldown-fill"></div>
<div class="cooldown-label">Gaswolke</div>
</div>
<div id="wave-bar-container"> <div id="wave-bar-container">
<div id="wave-bar-bg"></div> <div id="wave-bar-bg"></div>
@@ -274,8 +296,7 @@
</div> </div>
<div id="level-up"> <div id="level-up">
<h2>Level Up!</h2> <h2>Level Up!</h2> <div id="offer-row">
<div id="offer-row">
<div id="offer-0" class="offer-card"> <div id="offer-0" class="offer-card">
<div id="offer-icon-0" class="offer-icon"></div> <div id="offer-icon-0" class="offer-icon"></div>
<div id="offer-title-0" class="offer-title"></div> <div id="offer-title-0" class="offer-title"></div>
@@ -298,19 +319,26 @@
<button id="btn-confirm" class="menu-button" disabled>Bestätigen (Enter)</button> <button id="btn-confirm" class="menu-button" disabled>Bestätigen (Enter)</button>
</div> </div>
<div id="pause-overlay">
<h2>Pausiert</h2>
<button id="btn-resume" class="menu-button">Weiter (ESC)</button>
<button id="btn-pause-menu" class="menu-button">Hauptmenü</button>
</div>
<div id="main-menu"> <div id="main-menu">
<h1>WACKELPETER</h1> <h1>WACKELPETER</h1>
<div class="subtitle">Ein Blob kämpft ums Überleben</div> <div class="subtitle">Ein Blob kämpft ums Überleben</div>
<div class="menu-controls"> <div class="menu-controls">
WASD: Bewegen · Space: Springen · Maus: Zielen<br> WASD: Bewegen · Space: Springen · Maus: Zielen · ESC: Pause<br>
Linksklick: Schwert · Rechtsklick: Feuerball (per Skill) · Q: Kettenblitz (per Skill) · E: Teleport (per Skill) Linksklick: Schwert · Rechtsklick: Feuerball (per Skill)<br>
Q, E, 14: Skills (werden nach Erwerb belegt)
</div> </div>
<input id="seed-input" type="text" placeholder="Seed (leer = zufällig)" /> <input id="seed-input" type="text" placeholder="Seed (leer = zufällig)" />
<button id="btn-start" class="menu-button">Start</button> <button id="btn-start" class="menu-button">Start</button>
</div> </div>
<div id="instructions"> <div id="instructions">
WASD: Move · Space: Jump · Mouse: Aim · Left Click: Sword · Right Click: Fireball · Q: Lightning · E: Teleport WASD: Move · Space: Jump · Mouse: Aim · Left Click: Sword · Right Click: Fireball · Q/E/1-4: Skills · ESC: Pause
</div> </div>
</div> </div>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
+99
View File
@@ -0,0 +1,99 @@
import * as THREE from 'three';
import type { Game } from './Game';
import { auraRadius, auraDamage, auraPush } from './Skills';
const TICK = 0.5;
export class Aura {
private group: THREE.Group;
private fill: THREE.Mesh;
private ring: THREE.Mesh;
private tickTimer = 0;
private pulse = 0;
constructor(scene: THREE.Scene) {
this.group = new THREE.Group();
this.fill = new THREE.Mesh(
new THREE.CircleGeometry(1, 48),
new THREE.MeshBasicMaterial({
color: 0x66ccff,
transparent: true,
opacity: 0.12,
depthWrite: false,
})
);
this.fill.rotation.x = -Math.PI / 2;
this.fill.position.y = 0.05;
this.fill.renderOrder = 998;
this.group.add(this.fill);
this.ring = new THREE.Mesh(
new THREE.RingGeometry(0.93, 1, 48),
new THREE.MeshBasicMaterial({
color: 0x88ddff,
transparent: true,
opacity: 0.5,
side: THREE.DoubleSide,
depthWrite: false,
})
);
this.ring.rotation.x = -Math.PI / 2;
this.ring.position.y = 0.06;
this.ring.renderOrder = 999;
this.group.add(this.ring);
scene.add(this.group);
}
update(dt: number, game: Game) {
const level = game.skillSystem.getLevel('aura');
if (level <= 0) return;
const radius = auraRadius(level);
this.group.position.x = game.player.body.position.x;
this.group.position.z = game.player.body.position.z;
this.group.scale.setScalar(radius);
if (this.pulse > 0) {
this.pulse -= dt * 2.5;
const p = Math.max(0, this.pulse);
const s = 1 + p * 0.12;
this.fill.scale.setScalar(s);
this.ring.scale.setScalar(s);
(this.ring.material as THREE.MeshBasicMaterial).opacity = 0.5 + p * 0.4;
}
this.tickTimer -= dt;
if (this.tickTimer > 0) return;
this.tickTimer = TICK;
const damage = auraDamage(level);
const push = auraPush(level);
const center = game.player.body.position;
for (const enemy of game.enemies) {
if (enemy.dead) continue;
const dist = enemy.body.position.distanceTo(center);
if (dist > radius + 0.4) continue;
enemy.takeDamage(damage, game, false, center);
const dir = new THREE.Vector3()
.subVectors(enemy.body.position, center);
dir.y = 0;
if (dir.lengthSq() < 0.01) {
dir.set(Math.random() - 0.5, 0, Math.random() - 0.5);
}
dir.normalize();
enemy.body.position.addScaledVector(dir, push);
game.clampToArena(enemy.body.position);
}
this.pulse = 1;
}
dispose(scene: THREE.Scene) {
scene.remove(this.group);
this.fill.geometry.dispose();
(this.fill.material as THREE.Material).dispose();
this.ring.geometry.dispose();
(this.ring.material as THREE.Material).dispose();
}
}
+109
View File
@@ -0,0 +1,109 @@
import * as THREE from 'three';
import type { Game } from './Game';
import type { Enemy } from './Enemy';
import { boomerangDamage, boomerangCount } from './Skills';
const ORBIT_RADIUS = 2.8;
const ORBIT_HEIGHT = 0.9;
const ORBIT_SPEED = 2.4;
const SPIN_SPEED = 14;
const HIT_CD = 0.6;
const HIT_RADIUS = 0.9;
export class Boomerang {
private meshes: THREE.Mesh[] = [];
private angle = 0;
private hitTimers = new Map<Enemy, number>();
constructor(scene: THREE.Scene) {
const shape = new THREE.Shape();
const outer = 1.05;
const inner = 0.5;
const half = (58 * Math.PI) / 180;
const capR = (outer - inner) / 2;
const mid = (outer + inner) / 2;
shape.moveTo(outer * Math.cos(-half), outer * Math.sin(-half));
shape.absarc(0, 0, outer, -half, half, false);
shape.absarc(
mid * Math.cos(half), mid * Math.sin(half),
capR, half, half + Math.PI, false
);
shape.absarc(0, 0, inner, half, -half, true);
shape.absarc(
mid * Math.cos(-half), mid * Math.sin(-half),
capR, -half + Math.PI, -half, true
);
shape.closePath();
const geom = new THREE.ExtrudeGeometry(shape, {
depth: 0.06,
bevelEnabled: true,
bevelThickness: 0.02,
bevelSize: 0.02,
bevelSegments: 2,
});
geom.center();
const mat = new THREE.MeshToonMaterial({ color: 0xc98a3d });
for (let i = 0; i < 3; i++) {
const mesh = new THREE.Mesh(geom, mat);
mesh.rotation.x = -Math.PI / 2;
mesh.castShadow = true;
mesh.visible = false;
scene.add(mesh);
this.meshes.push(mesh);
}
}
setLevel(level: number) {
const count = level > 0 ? boomerangCount(level) : 0;
for (let i = 0; i < this.meshes.length; i++) {
this.meshes[i].visible = i < count;
}
}
update(dt: number, game: Game) {
const level = game.skillSystem.getLevel('boomerang');
if (level <= 0) return;
const count = boomerangCount(level);
const damage = boomerangDamage(level);
this.angle += ORBIT_SPEED * dt;
const center = game.player.body.position;
for (const [enemy, t] of this.hitTimers) {
this.hitTimers.set(enemy, t - dt);
}
for (let i = 0; i < count; i++) {
const mesh = this.meshes[i];
const a = this.angle + (i / count) * Math.PI * 2;
mesh.position.set(
center.x + Math.cos(a) * ORBIT_RADIUS,
ORBIT_HEIGHT + Math.sin(this.angle * 2 + i) * 0.15,
center.z + Math.sin(a) * ORBIT_RADIUS
);
mesh.rotation.z += SPIN_SPEED * dt;
for (const enemy of game.enemies) {
if (enemy.dead) continue;
const timer = this.hitTimers.get(enemy) ?? 0;
if (timer > 0) continue;
const dist = enemy.body.position.distanceTo(mesh.position);
if (dist < HIT_RADIUS + 0.5) {
this.hitTimers.set(enemy, HIT_CD);
enemy.takeDamage(damage, game, true, mesh.position);
}
}
}
}
dispose(scene: THREE.Scene) {
for (const mesh of this.meshes) {
scene.remove(mesh);
mesh.geometry.dispose();
(mesh.material as THREE.Material).dispose();
}
this.meshes = [];
}
}
+1
View File
@@ -7,6 +7,7 @@ export abstract class Enemy {
dead = false; dead = false;
deathTimer = 0; deathTimer = 0;
knockback = 0; knockback = 0;
rootTime = 0;
health = 100; health = 100;
speed = 6; speed = 6;
xp = 10; xp = 10;
+215 -8
View File
@@ -18,12 +18,20 @@ 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 { SlashEffect, isInSlashArc } from './SwordTrail';
import { SkillSystem, getSkill, type SkillOffer } from './Skills'; import { SkillSystem, getSkill, HOTBAR_SKILLS, type SkillOffer } from './Skills';
import { skillIcon } from './SkillIcons'; import { skillIcon } from './SkillIcons';
import { initSounds, playSound, resumeContext } from './SoundManager'; import { initSounds, playSound, resumeContext } from './SoundManager';
import { Aura } from './Aura';
import { Boomerang } from './Boomerang';
import { Trap } from './Trap';
import { GasCloud } from './GasCloud';
import { Trapper } from './Trapper';
const SPAWN_TIME = 10; const SPAWN_TIME = 10;
const HOTBAR_KEY_CODES = ['KeyQ', 'KeyE', 'Digit1', 'Digit2', 'Digit3', 'Digit4'];
const HOTBAR_KEY_LABELS = ['Q', 'E', '1', '2', '3', '4'];
type GameState = 'menu' | 'playing' | 'gameover' | 'levelup'; type GameState = 'menu' | 'playing' | 'gameover' | 'levelup';
export class Game { export class Game {
@@ -38,8 +46,15 @@ export class Game {
crosses: Cross[] = []; crosses: Cross[] = [];
slashEffects: SlashEffect[] = []; slashEffects: SlashEffect[] = [];
projectiles: EnemyProjectile[] = []; projectiles: EnemyProjectile[] = [];
traps: Trap[] = [];
gasClouds: GasCloud[] = [];
private aura: Aura | null = null;
private boomerang: Boomerang | null = null;
private trapper = new Trapper();
private hotbar: (string | null)[] = Array(HOTBAR_KEY_CODES.length).fill(null);
state: GameState = 'menu'; state: GameState = 'menu';
private paused = false;
skillSystem: SkillSystem = new SkillSystem(0); skillSystem: SkillSystem = new SkillSystem(0);
private seed: number | null = null; private seed: number | null = null;
private offers: SkillOffer[] = []; private offers: SkillOffer[] = [];
@@ -98,6 +113,15 @@ export class Game {
private uiOfferDescs: HTMLElement[] = []; private uiOfferDescs: HTMLElement[] = [];
private uiDamageVignette!: HTMLElement; private uiDamageVignette!: HTMLElement;
private uiLowHpVignette!: HTMLElement; private uiLowHpVignette!: HTMLElement;
private uiTrapContainer!: HTMLElement;
private uiTrapCD!: HTMLElement;
private uiGasContainer!: HTMLElement;
private uiGasCD!: HTMLElement;
private uiLightningLabel!: HTMLElement;
private uiTeleportLabel!: HTMLElement;
private uiTrapLabel!: HTMLElement;
private uiGasLabel!: HTMLElement;
private uiPauseOverlay!: HTMLElement;
private mouseOnCanvas = false; private mouseOnCanvas = false;
private bloodParticles: { private bloodParticles: {
@@ -189,6 +213,14 @@ export class Game {
this.uiConfirmBtn.addEventListener('click', () => this.confirmOffer()); this.uiConfirmBtn.addEventListener('click', () => this.confirmOffer());
this.uiDamageVignette = document.getElementById('damage-vignette')!; this.uiDamageVignette = document.getElementById('damage-vignette')!;
this.uiLowHpVignette = document.getElementById('low-hp-vignette')!; this.uiLowHpVignette = document.getElementById('low-hp-vignette')!;
this.uiTrapContainer = document.getElementById('trap-cooldown')!;
this.uiTrapCD = document.querySelector('#trap-cooldown .cooldown-fill')!;
this.uiGasContainer = document.getElementById('gas-cooldown')!;
this.uiGasCD = document.querySelector('#gas-cooldown .cooldown-fill')!;
this.uiLightningLabel = document.querySelector('#lightning-cooldown .cooldown-label')!;
this.uiTeleportLabel = document.querySelector('#teleport-cooldown .cooldown-label')!;
this.uiTrapLabel = document.querySelector('#trap-cooldown .cooldown-label')!;
this.uiGasLabel = document.querySelector('#gas-cooldown .cooldown-label')!;
for (let i = 0; i < 3; i++) { for (let i = 0; i < 3; i++) {
const card = document.getElementById(`offer-${i}`)!; const card = document.getElementById(`offer-${i}`)!;
@@ -201,6 +233,9 @@ export class Game {
document.getElementById('btn-start')!.addEventListener('click', () => this.startGame()); document.getElementById('btn-start')!.addEventListener('click', () => this.startGame());
document.getElementById('btn-restart')!.addEventListener('click', () => this.restart()); document.getElementById('btn-restart')!.addEventListener('click', () => this.restart());
document.getElementById('btn-menu')!.addEventListener('click', () => this.showMenu()); document.getElementById('btn-menu')!.addEventListener('click', () => this.showMenu());
this.uiPauseOverlay = document.getElementById('pause-overlay')!;
document.getElementById('btn-resume')!.addEventListener('click', () => this.resume());
document.getElementById('btn-pause-menu')!.addEventListener('click', () => this.showMenu());
// Diagnostics: detect a full page reload that wiped a running game // Diagnostics: detect a full page reload that wiped a running game
if (sessionStorage.getItem('wackelpeter-run') === '1') { if (sessionStorage.getItem('wackelpeter-run') === '1') {
@@ -224,6 +259,20 @@ export class Game {
private setupInput() { private setupInput() {
window.addEventListener('keydown', (e) => { window.addEventListener('keydown', (e) => {
this.keys.add(e.code); this.keys.add(e.code);
if (this.paused) {
if (e.code === 'Escape') this.resume();
e.preventDefault();
return;
}
if (e.code === 'Escape') {
if (this.state === 'playing') {
this.pause();
} else if (this.state === 'gameover') {
this.showMenu();
}
e.preventDefault();
return;
}
if (e.code === 'Space' && this.state === 'playing') { if (e.code === 'Space' && this.state === 'playing') {
this.player.jump(); this.player.jump();
e.preventDefault(); e.preventDefault();
@@ -231,8 +280,9 @@ export class Game {
if (e.code === 'KeyH') { if (e.code === 'KeyH') {
this.showHitzones = !this.showHitzones; this.showHitzones = !this.showHitzones;
} }
if (e.code === 'KeyQ' && this.state === 'playing') { const slotIdx = HOTBAR_KEY_CODES.indexOf(e.code);
this.player.skillLightning.skill1(this.groundPoint, this.player); if (slotIdx >= 0 && this.state === 'playing' && this.hotbar[slotIdx]) {
this.triggerHotbarSkill(this.hotbar[slotIdx]!);
} }
if (this.state === 'levelup') { if (this.state === 'levelup') {
if (e.code === 'Digit1' || e.code === 'Numpad1') this.selectOffer(0); if (e.code === 'Digit1' || e.code === 'Numpad1') this.selectOffer(0);
@@ -254,9 +304,6 @@ export class Game {
}); });
window.addEventListener('keyup', (e) => { window.addEventListener('keyup', (e) => {
this.keys.delete(e.code); this.keys.delete(e.code);
if (e.code === 'KeyE' && this.state === 'playing') {
this.player.leftHandWeapon.skill2(this.groundPoint, this.player);
}
}); });
window.addEventListener('mousedown', (e) => { window.addEventListener('mousedown', (e) => {
@@ -276,7 +323,7 @@ export class Game {
} }
private handleMouseRelease(button: number) { private handleMouseRelease(button: number) {
if (this.state !== 'playing') return; if (this.state !== 'playing' || this.paused) return;
if (button === 0) { if (button === 0) {
this.player.rightHandWeapon.skill1(this.groundPoint, this.player); this.player.rightHandWeapon.skill1(this.groundPoint, this.player);
} else if (button === 2) { } else if (button === 2) {
@@ -284,6 +331,33 @@ export class Game {
} }
} }
private triggerHotbarSkill(id: string) {
switch (id) {
case 'chainlightning':
this.player.skillLightning.skill1(this.groundPoint, this.player);
break;
case 'teleport':
this.player.leftHandWeapon.skill2(this.groundPoint, this.player);
break;
case 'trap':
this.trapper.skill1(this.groundPoint, this.player);
break;
case 'gascloud':
this.trapper.skill2(this.groundPoint, this.player);
break;
}
}
private hotbarKeyLabel(skillId: string): string {
const idx = this.hotbar.indexOf(skillId);
return idx >= 0 ? HOTBAR_KEY_LABELS[idx] : '';
}
private nextFreeHotbarLabel(): string {
const idx = this.hotbar.indexOf(null);
return idx >= 0 ? HOTBAR_KEY_LABELS[idx] : '';
}
private setupLighting() { private setupLighting() {
const ambient = new THREE.AmbientLight(0x556688, 0.35); const ambient = new THREE.AmbientLight(0x556688, 0.35);
this.scene.add(ambient); this.scene.add(ambient);
@@ -416,13 +490,17 @@ export class Game {
this.resetWorld(); this.resetWorld();
this.logState('playing', 'startGame'); this.logState('playing', 'startGame');
this.state = 'playing'; this.state = 'playing';
this.paused = false;
this.hidePauseOverlay();
sessionStorage.setItem('wackelpeter-run', '1'); sessionStorage.setItem('wackelpeter-run', '1');
this.uiMainMenu.style.opacity = '0'; this.uiMainMenu.style.opacity = '0';
this.uiMainMenu.style.pointerEvents = 'none'; this.uiMainMenu.style.pointerEvents = 'none';
this.uiMainMenu.style.visibility = 'hidden';
this.uiGameOver.style.opacity = '0'; this.uiGameOver.style.opacity = '0';
this.uiGameOverPanel.style.transition = 'opacity 0.3s ease'; this.uiGameOverPanel.style.transition = 'opacity 0.3s ease';
this.uiGameOverPanel.style.opacity = '0'; this.uiGameOverPanel.style.opacity = '0';
this.uiGameOverPanel.style.pointerEvents = 'none'; this.uiGameOverPanel.style.pointerEvents = 'none';
this.uiGameOverPanel.style.visibility = 'hidden';
this.blurFocus(); this.blurFocus();
} }
@@ -435,7 +513,34 @@ export class Game {
this.startGame(); this.startGame();
} }
private pause() {
if (this.state !== 'playing' || this.paused) return;
this.paused = true;
this.uiPauseOverlay.style.visibility = 'visible';
this.uiPauseOverlay.style.opacity = '1';
this.uiPauseOverlay.style.pointerEvents = 'auto';
this.blurFocus();
}
private resume() {
if (!this.paused) return;
this.paused = false;
this.hidePauseOverlay();
this.blurFocus();
}
private hidePauseOverlay() {
this.uiPauseOverlay.style.opacity = '0';
this.uiPauseOverlay.style.pointerEvents = 'none';
this.uiPauseOverlay.style.visibility = 'hidden';
}
showMenu() { showMenu() {
if ((this.state === 'playing' || this.state === 'levelup') && !this.paused) {
return;
}
this.paused = false;
this.hidePauseOverlay();
this.resetWorld(); this.resetWorld();
this.logState('menu', 'showMenu'); this.logState('menu', 'showMenu');
this.state = 'menu'; this.state = 'menu';
@@ -445,8 +550,10 @@ export class Game {
this.uiGameOverPanel.style.transition = 'opacity 0.3s ease'; this.uiGameOverPanel.style.transition = 'opacity 0.3s ease';
this.uiGameOverPanel.style.opacity = '0'; this.uiGameOverPanel.style.opacity = '0';
this.uiGameOverPanel.style.pointerEvents = 'none'; this.uiGameOverPanel.style.pointerEvents = 'none';
this.uiGameOverPanel.style.visibility = 'hidden';
this.uiMainMenu.style.opacity = '1'; this.uiMainMenu.style.opacity = '1';
this.uiMainMenu.style.pointerEvents = 'auto'; this.uiMainMenu.style.pointerEvents = 'auto';
this.uiMainMenu.style.visibility = 'visible';
this.blurFocus(); this.blurFocus();
} }
@@ -461,6 +568,7 @@ export class Game {
this.uiGameOverPanel.style.transition = 'opacity 0.8s ease-in 1.5s'; this.uiGameOverPanel.style.transition = 'opacity 0.8s ease-in 1.5s';
this.uiGameOverPanel.style.opacity = '1'; this.uiGameOverPanel.style.opacity = '1';
this.uiGameOverPanel.style.pointerEvents = 'auto'; this.uiGameOverPanel.style.pointerEvents = 'auto';
this.uiGameOverPanel.style.visibility = 'visible';
playSound('gameover', 0.8); playSound('gameover', 0.8);
} }
@@ -483,6 +591,25 @@ export class Game {
for (const p of this.projectiles) p.dispose(); for (const p of this.projectiles) p.dispose();
this.projectiles = []; this.projectiles = [];
for (const trap of this.traps) trap.dispose();
this.traps = [];
for (const cloud of this.gasClouds) cloud.dispose();
this.gasClouds = [];
if (this.aura) {
this.aura.dispose(this.scene);
this.aura = null;
}
if (this.boomerang) {
this.boomerang.dispose(this.scene);
this.boomerang = null;
}
this.hotbar = Array(HOTBAR_KEY_CODES.length).fill(null);
this.trapper.cooldown1 = 0;
this.trapper.cooldown2 = 0;
this.trapper.cooldown3 = 0;
for (const [enemy, ring] of this.enemyHitRings) { for (const [enemy, ring] of this.enemyHitRings) {
this.scene.remove(ring); this.scene.remove(ring);
ring.geometry.dispose(); ring.geometry.dispose();
@@ -556,7 +683,12 @@ export class Game {
const isNew = offer.nextLevel === 1; const isNew = offer.nextLevel === 1;
this.uiOfferIcons[i].innerHTML = skillIcon(offer.id); this.uiOfferIcons[i].innerHTML = skillIcon(offer.id);
title.textContent = `${skill.name}${isNew ? ' (NEU)' : ` · Stufe ${offer.nextLevel}`}`; title.textContent = `${skill.name}${isNew ? ' (NEU)' : ` · Stufe ${offer.nextLevel}`}`;
desc.textContent = skill.describe(offer.nextLevel); let descText = skill.describe(offer.nextLevel);
if (isNew && HOTBAR_SKILLS.includes(offer.id)) {
const key = this.nextFreeHotbarLabel();
if (key) descText += ` · Taste [${key}]`;
}
desc.textContent = descText;
card.style.display = ''; card.style.display = '';
card.classList.remove('selected'); card.classList.remove('selected');
} else { } else {
@@ -564,6 +696,7 @@ export class Game {
} }
} }
this.uiConfirmBtn.disabled = true; this.uiConfirmBtn.disabled = true;
this.uiLevelUp.style.visibility = 'visible';
this.uiLevelUp.style.opacity = '1'; this.uiLevelUp.style.opacity = '1';
this.uiLevelUp.style.pointerEvents = 'auto'; this.uiLevelUp.style.pointerEvents = 'auto';
} }
@@ -571,6 +704,7 @@ export class Game {
private hideLevelUp() { private hideLevelUp() {
this.uiLevelUp.style.opacity = '0'; this.uiLevelUp.style.opacity = '0';
this.uiLevelUp.style.pointerEvents = 'none'; this.uiLevelUp.style.pointerEvents = 'none';
this.uiLevelUp.style.visibility = 'hidden';
} }
private selectOffer(index: number) { private selectOffer(index: number) {
@@ -588,6 +722,10 @@ export class Game {
const offer = this.offers[this.selectedOffer]; const offer = this.offers[this.selectedOffer];
this.selectedOffer = -1; this.selectedOffer = -1;
this.skillSystem.apply(offer.id); this.skillSystem.apply(offer.id);
if (HOTBAR_SKILLS.includes(offer.id) && !this.hotbar.includes(offer.id)) {
const free = this.hotbar.indexOf(null);
if (free >= 0) this.hotbar[free] = offer.id;
}
this.player.applySkillSystem(this.skillSystem); this.player.applySkillSystem(this.skillSystem);
this.syncSkillVisibility(); this.syncSkillVisibility();
this.blurFocus(); this.blurFocus();
@@ -610,6 +748,30 @@ export class Game {
this.uiFireballContainer.style.display = s.getLevel('fireball') > 0 ? '' : 'none'; this.uiFireballContainer.style.display = s.getLevel('fireball') > 0 ? '' : 'none';
this.uiTeleportContainer.style.display = s.getLevel('teleport') > 0 ? '' : 'none'; this.uiTeleportContainer.style.display = s.getLevel('teleport') > 0 ? '' : 'none';
this.uiLightningContainer.style.display = s.getLevel('chainlightning') > 0 ? '' : 'none'; this.uiLightningContainer.style.display = s.getLevel('chainlightning') > 0 ? '' : 'none';
this.uiTrapContainer.style.display = s.getLevel('trap') > 0 ? '' : 'none';
this.uiGasContainer.style.display = s.getLevel('gascloud') > 0 ? '' : 'none';
const auraLevel = s.getLevel('aura');
if (auraLevel > 0 && !this.aura) {
this.aura = new Aura(this.scene);
} else if (auraLevel <= 0 && this.aura) {
this.aura.dispose(this.scene);
this.aura = null;
}
const boomerangLevel = s.getLevel('boomerang');
if (boomerangLevel > 0 && !this.boomerang) {
this.boomerang = new Boomerang(this.scene);
} else if (boomerangLevel <= 0 && this.boomerang) {
this.boomerang.dispose(this.scene);
this.boomerang = null;
}
if (this.boomerang) this.boomerang.setLevel(boomerangLevel);
this.uiLightningLabel.textContent = `${getSkill('chainlightning').name} [${this.hotbarKeyLabel('chainlightning')}]`;
this.uiTeleportLabel.textContent = `${getSkill('teleport').name} [${this.hotbarKeyLabel('teleport')}]`;
this.uiTrapLabel.textContent = `${getSkill('trap').name} [${this.hotbarKeyLabel('trap')}]`;
this.uiGasLabel.textContent = `${getSkill('gascloud').name} [${this.hotbarKeyLabel('gascloud')}]`;
} }
private spawnEnemy() { private spawnEnemy() {
@@ -780,6 +942,11 @@ export class Game {
enemy.updateAnimation(dt); enemy.updateAnimation(dt);
continue; continue;
} }
if (enemy.rootTime > 0) {
enemy.rootTime -= dt;
enemy.updateAnimation(dt);
continue;
}
enemy.update(dt, this); enemy.update(dt, this);
enemy.updateAnimation(dt); enemy.updateAnimation(dt);
} }
@@ -873,6 +1040,14 @@ export class Game {
const lnPct = Math.max(0, (lightning.COOLDOWN1_TIME - lightning.cooldown1) / lightning.COOLDOWN1_TIME); const lnPct = Math.max(0, (lightning.COOLDOWN1_TIME - lightning.cooldown1) / lightning.COOLDOWN1_TIME);
this.uiLightningCD.style.width = `${lnPct * 100}%`; this.uiLightningCD.style.width = `${lnPct * 100}%`;
const trapTotal = this.trapper.COOLDOWN1_TIME || 1;
const trapPct = Math.max(0, (trapTotal - this.trapper.cooldown1) / trapTotal);
this.uiTrapCD.style.width = `${trapPct * 100}%`;
const gasTotal = this.trapper.COOLDOWN2_TIME || 1;
const gasPct = Math.max(0, (gasTotal - this.trapper.cooldown2) / gasTotal);
this.uiGasCD.style.width = `${gasPct * 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`;
@@ -947,11 +1122,37 @@ export class Game {
} }
} }
private updateTraps(dt: number) {
for (let i = this.traps.length - 1; i >= 0; i--) {
const trap = this.traps[i];
if (!trap.update(dt, this)) {
trap.dispose();
this.traps.splice(i, 1);
}
}
}
private updateGasClouds(dt: number) {
for (let i = this.gasClouds.length - 1; i >= 0; i--) {
const cloud = this.gasClouds[i];
if (!cloud.update(dt, this)) {
cloud.dispose();
this.gasClouds.splice(i, 1);
}
}
}
private animate() { private animate() {
requestAnimationFrame(() => this.animate()); requestAnimationFrame(() => this.animate());
const dt = Math.min(this.clock.getDelta(), 0.1); const dt = Math.min(this.clock.getDelta(), 0.1);
if (this.paused) {
this.updateUI();
this.renderer.render(this.scene, this.camera);
return;
}
this.updateGroundPoint(); this.updateGroundPoint();
this.updatePlayerMovement(dt); this.updatePlayerMovement(dt);
this.updateEnemies(dt); this.updateEnemies(dt);
@@ -967,6 +1168,11 @@ export class Game {
this.updateBloodParticles(dt); this.updateBloodParticles(dt);
if (this.aura) this.aura.update(dt, this);
if (this.boomerang) this.boomerang.update(dt, this);
this.updateTraps(dt);
this.updateGasClouds(dt);
// Update animations // Update animations
this.player.updateAnimations(dt); this.player.updateAnimations(dt);
this.player.rightHandWeapon.updateAnimation(dt); this.player.rightHandWeapon.updateAnimation(dt);
@@ -1036,6 +1242,7 @@ export class Game {
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); this.player.skillLightning.reduceCooldowns(dt);
this.trapper.reduceCooldowns(dt);
// Camera follows player // Camera follows player
this.camera.position.set( this.camera.position.set(
+79
View File
@@ -0,0 +1,79 @@
import * as THREE from 'three';
import type { Game } from './Game';
import { gasDamage, gasRadius, gasDuration } from './Skills';
const TICK = 0.5;
export class GasCloud {
body: THREE.Group;
private level: number;
private life: number;
private duration: number;
private radius: number;
private tickTimer = 0;
private puffs: THREE.Mesh[] = [];
constructor(position: THREE.Vector3, level: number) {
this.level = level;
this.radius = gasRadius(level);
this.duration = gasDuration(level);
this.life = this.duration;
this.body = new THREE.Group();
this.body.position.copy(position);
this.body.position.y = 0;
const puffGeom = new THREE.SphereGeometry(1, 16, 12);
const puffMat = new THREE.MeshToonMaterial({
color: 0x66bb44,
transparent: true,
opacity: 0.3,
depthWrite: false,
});
for (let i = 0; i < 5; i++) {
const puff = new THREE.Mesh(puffGeom, puffMat);
const a = (i / 5) * Math.PI * 2;
puff.position.set(
Math.cos(a) * this.radius * 0.35,
0.6 + Math.random() * 0.5,
Math.sin(a) * this.radius * 0.35
);
puff.scale.setScalar(this.radius * (0.3 + Math.random() * 0.2));
this.body.add(puff);
this.puffs.push(puff);
}
}
update(dt: number, game: Game): boolean {
this.life -= dt;
if (this.life <= 0) return false;
const fade = Math.min(1, this.life / 0.6);
for (const puff of this.puffs) {
(puff.material as THREE.MeshToonMaterial).opacity = 0.3 * fade;
puff.rotation.y += dt * 0.2;
}
this.tickTimer -= dt;
if (this.tickTimer <= 0) {
this.tickTimer = TICK;
const damage = gasDamage(this.level) * TICK;
for (const enemy of game.enemies) {
if (enemy.dead) continue;
const dist = enemy.body.position.distanceTo(this.body.position);
if (dist < this.radius) {
enemy.takeDamage(damage, game, false, this.body.position);
}
}
}
return true;
}
dispose() {
this.body.removeFromParent();
for (const puff of this.puffs) {
puff.geometry.dispose();
(puff.material as THREE.Material).dispose();
}
}
}
+88
View File
@@ -203,6 +203,94 @@ const ICONS: Record<string, string> = {
<rect x="20.5" y="5" width="16" height="6.5" rx="2.5" fill="#ffcf5e" stroke="#6e4a12" stroke-width="2"/> <rect x="20.5" y="5" width="16" height="6.5" rx="2.5" fill="#ffcf5e" stroke="#6e4a12" stroke-width="2"/>
<path d="M27 48 h24" stroke="#6e4a12" stroke-width="2" opacity="0.6"/> <path d="M27 48 h24" stroke="#6e4a12" stroke-width="2" opacity="0.6"/>
</svg>`, </svg>`,
aura: `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="auRing" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#7fe3ff"/>
<stop offset="100%" stop-color="#3a7bd6"/>
</linearGradient>
<radialGradient id="auGlow" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="#7fe3ff" stop-opacity="0.35"/>
<stop offset="70%" stop-color="#3a7bd6" stop-opacity="0.12"/>
<stop offset="100%" stop-color="#3a7bd6" stop-opacity="0"/>
</radialGradient>
</defs>
<circle cx="32" cy="32" r="28" fill="url(#auGlow)"/>
<circle cx="32" cy="32" r="24" fill="none" stroke="url(#auRing)" stroke-width="3.5"/>
<circle cx="32" cy="32" r="16" fill="none" stroke="#7fe3ff" stroke-width="1.8" stroke-dasharray="5 6" opacity="0.8"/>
<circle cx="32" cy="32" r="8" fill="none" stroke="#a8ecff" stroke-width="1.5" opacity="0.7"/>
<path d="M54 12 l1.8 3.4 3.4 1.8 -3.4 1.8 -1.8 3.4 -1.8 -3.4 -3.4 -1.8 3.4 -1.8 Z" fill="#a8ecff"/>
<path d="M10 46 l1.4 2.6 2.6 1.4 -2.6 1.4 -1.4 2.6 -1.4 -2.6 -2.6 -1.4 2.6 -1.4 Z" fill="#7fe3ff" opacity="0.9"/>
</svg>`,
boomerang: `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="bmWood" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#e8a94e"/>
<stop offset="55%" stop-color="#b26b25"/>
<stop offset="100%" stop-color="#7c4715"/>
</linearGradient>
</defs>
<circle cx="32" cy="32" r="26" fill="#e8a94e" opacity="0.1"/>
<circle cx="32" cy="32" r="24" fill="none" stroke="#e8a94e" stroke-width="1.5" stroke-dasharray="2 6" stroke-linecap="round" opacity="0.6"/>
<path d="M44 13 C51 21 51 30 45 36 C39.5 41.5 31 40 27 34 L25 36.5 C28.5 45 40 47.5 47 42.5 C55 37 56 23.5 50 15.5 L44 13 Z"
fill="url(#bmWood)" stroke="#4a2a0c" stroke-width="2.5" stroke-linejoin="round"/>
<path d="M45 16 C50 22.5 50 29 45 34 C41 38 34.5 37 31 32.5" fill="none" stroke="#ffd98a" stroke-width="1.6" opacity="0.75"/>
<path d="M20 44 l1.8 3.2 3.2 1.8 -3.2 1.8 -1.8 3.2 -1.8 -3.2 -3.2 -1.8 3.2 -1.8 Z" fill="#ffd98a"/>
</svg>`,
trap: `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="trJaw" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#c9d4e0"/>
<stop offset="60%" stop-color="#7e93a8"/>
<stop offset="100%" stop-color="#4a5a6c"/>
</linearGradient>
</defs>
<circle cx="32" cy="34" r="25" fill="#7e93a8" opacity="0.1"/>
<path d="M10 24 A22 22 0 0 1 54 24 L54 30 L10 30 Z" fill="url(#trJaw)" stroke="#2a3440" stroke-width="2" stroke-linejoin="round"/>
<path d="M10 26 A22 22 0 0 1 54 26 L54 24 L10 24 Z" fill="#eef4fa" opacity="0.6"/>
<g fill="#eef4fa" stroke="#2a3440" stroke-width="1.4">
<path d="M13 30 l2.4 7 -4.8 0 Z"/>
<path d="M23 30 l2.4 8 -4.8 0 Z"/>
<path d="M33 30 l2.4 8 -4.8 0 Z"/>
<path d="M43 30 l2.4 8 -4.8 0 Z"/>
<path d="M53 30 l2.4 7 -4.8 0 Z"/>
</g>
<path d="M20 40 h24" stroke="#2a3440" stroke-width="2" opacity="0.7"/>
<path d="M15 12 l1.6 3 3 1.6 -3 1.6 -1.6 3 -1.6 -3 -3 -1.6 3 -1.6 Z" fill="#ffcc00"/>
<circle cx="50" cy="13" r="1.6" fill="#eef4fa"/>
</svg>`,
gascloud: `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<radialGradient id="gcPuff" cx="40%" cy="35%" r="75%">
<stop offset="0%" stop-color="#b8f28a"/>
<stop offset="55%" stop-color="#5cae3a"/>
<stop offset="100%" stop-color="#2f6b1c"/>
</radialGradient>
</defs>
<circle cx="32" cy="38" r="25" fill="#5cae3a" opacity="0.12"/>
<g fill="url(#gcPuff)" stroke="#1c3d10" stroke-width="2" stroke-linejoin="round">
<circle cx="24" cy="40" r="11"/>
<circle cx="40" cy="36" r="13"/>
<circle cx="32" cy="28" r="10"/>
<circle cx="44" cy="47" r="7"/>
<circle cx="18" cy="46" r="6"/>
</g>
<g fill="#c9f7a0" opacity="0.55">
<circle cx="21" cy="37" r="3.4"/>
<circle cx="37" cy="33" r="3.8"/>
<circle cx="30" cy="25" r="3"/>
</g>
<circle cx="50" cy="14" r="1.6" fill="#c9f7a0"/>
<circle cx="12" cy="20" r="1.4" fill="#b8f28a"/>
</svg>`,
}; };
export function skillIcon(id: string): string { export function skillIcon(id: string): string {
+71
View File
@@ -13,6 +13,8 @@ export interface SkillOffer {
nextLevel: number; nextLevel: number;
} }
export const HOTBAR_SKILLS = ['chainlightning', 'teleport', 'trap', 'gascloud'];
export const SKILLS: SkillDef[] = [ export const SKILLS: SkillDef[] = [
{ {
id: 'fireball', id: 'fireball',
@@ -93,6 +95,36 @@ export const SKILLS: SkillDef[] = [
maxLevel: 3, maxLevel: 3,
describe: (l) => `Bewegungsgeschwindigkeit ${(7.5 + l).toFixed(1)}`, describe: (l) => `Bewegungsgeschwindigkeit ${(7.5 + l).toFixed(1)}`,
}, },
{
id: 'aura',
name: 'Aura',
type: 'passive',
maxLevel: 5,
describe: (l) => l === 1
? 'Schädigt Gegner um dich herum und stößt sie zurück'
: `Radius ${auraRadius(l).toFixed(1)} · ${auraDamage(l)} DMG/0,5s`,
},
{
id: 'boomerang',
name: 'Bumerang',
type: 'passive',
maxLevel: 5,
describe: (l) => `${boomerangCount(l)}× kreisender Bumerang · ${boomerangDamage(l)} DMG`,
},
{
id: 'trap',
name: 'Falle',
type: 'active',
maxLevel: 5,
describe: (l) => `Hält Gegner ${trapHold(l)}s fest (${trapDamage(l)} DMG, ${trapCooldown(l)}s CD)`,
},
{
id: 'gascloud',
name: 'Gaswolke',
type: 'active',
maxLevel: 5,
describe: (l) => `${gasDamage(l)} DPS · Radius ${gasRadius(l)} · ${gasCooldown(l)}s CD`,
},
]; ];
export function getSkill(id: string): SkillDef { export function getSkill(id: string): SkillDef {
@@ -126,6 +158,45 @@ export function swordRange(level: number): number {
export function swordDamage(level: number): number { export function swordDamage(level: number): number {
return 50 + 25 * level; return 50 + 25 * level;
} }
export function auraRadius(level: number): number {
return [3, 3.4, 3.8, 4.2, 4.6][Math.min(level, 5) - 1] ?? 3;
}
export function auraDamage(level: number): number {
return [8, 12, 16, 20, 26][Math.min(level, 5) - 1] ?? 8;
}
export function auraPush(level: number): number {
return [0.8, 0.9, 1.0, 1.1, 1.2][Math.min(level, 5) - 1] ?? 0.8;
}
export function boomerangDamage(level: number): number {
return [12, 18, 24, 32, 40][Math.min(level, 5) - 1] ?? 12;
}
export function boomerangCount(level: number): number {
return [1, 1, 2, 2, 3][Math.min(level, 5) - 1] ?? 1;
}
export function trapDamage(level: number): number {
return [25, 35, 45, 55, 70][Math.min(level, 5) - 1] ?? 25;
}
export function trapHold(level: number): number {
return [1.5, 1.75, 2, 2.25, 2.5][Math.min(level, 5) - 1] ?? 1.5;
}
export function trapMax(level: number): number {
return [3, 3, 4, 4, 5][Math.min(level, 5) - 1] ?? 3;
}
export function trapCooldown(level: number): number {
return [8, 7, 6.5, 6, 5][Math.min(level, 5) - 1] ?? 8;
}
export function gasDamage(level: number): number {
return [8, 12, 16, 20, 25][Math.min(level, 5) - 1] ?? 8;
}
export function gasRadius(level: number): number {
return [3.5, 4, 4.5, 5, 5.5][Math.min(level, 5) - 1] ?? 3.5;
}
export function gasDuration(level: number): number {
return [4, 4.5, 5, 5.5, 6][Math.min(level, 5) - 1] ?? 4;
}
export function gasCooldown(level: number): number {
return [10, 9, 8.5, 8, 7][Math.min(level, 5) - 1] ?? 10;
}
function mulberry32(seed: number): () => number { function mulberry32(seed: number): () => number {
let a = seed >>> 0; let a = seed >>> 0;
+108
View File
@@ -0,0 +1,108 @@
import * as THREE from 'three';
import type { Game } from './Game';
import type { Enemy } from './Enemy';
import { trapDamage, trapHold } from './Skills';
const TRIGGER_RADIUS = 1.1;
const LIFETIME = 20;
const TICK = 0.5;
export class Trap {
body: THREE.Group;
private level: number;
private life = LIFETIME;
private triggered = false;
private tickTimer = 0;
private target: Enemy | null = null;
private jaws: THREE.Mesh[];
private pulse = 0;
constructor(position: THREE.Vector3, level: number) {
this.level = level;
this.body = new THREE.Group();
this.body.position.copy(position);
this.body.position.y = 0;
const baseGeom = new THREE.CylinderGeometry(0.45, 0.55, 0.12, 24);
const baseMat = new THREE.MeshToonMaterial({ color: 0x556066 });
const base = new THREE.Mesh(baseGeom, baseMat);
base.position.y = 0.06;
base.castShadow = true;
this.body.add(base);
const ringGeom = new THREE.TorusGeometry(0.35, 0.05, 8, 24);
const ringMat = new THREE.MeshBasicMaterial({
color: 0xffcc33,
transparent: true,
opacity: 0.8,
});
const ring = new THREE.Mesh(ringGeom, ringMat);
ring.rotation.x = -Math.PI / 2;
ring.position.y = 0.13;
this.body.add(ring);
this.jaws = [];
for (const side of [-1, 1]) {
const jaw = new THREE.Mesh(
new THREE.BoxGeometry(0.3, 0.06, 0.55),
new THREE.MeshToonMaterial({ color: 0x9aa7b0 })
);
jaw.position.set(side * 0.2, 0.14, 0);
jaw.castShadow = true;
this.body.add(jaw);
this.jaws.push(jaw);
}
}
update(dt: number, game: Game): boolean {
if (!this.triggered) {
this.life -= dt;
if (this.life <= 0) return false;
for (const enemy of game.enemies) {
if (enemy.dead) continue;
const dist = enemy.body.position.distanceTo(this.body.position);
if (dist < TRIGGER_RADIUS) {
this.triggered = true;
this.target = enemy;
enemy.rootTime = trapHold(this.level);
this.pulse = 1;
break;
}
}
return true;
}
this.tickTimer -= dt;
if (this.pulse > 0) {
this.pulse -= dt * 4;
for (const jaw of this.jaws) {
jaw.scale.setScalar(1 + Math.max(0, this.pulse) * 0.6);
}
}
if (!this.target || this.target.dead || this.target.rootTime <= 0) {
return false;
}
if (this.tickTimer <= 0) {
this.tickTimer = TICK;
this.target.takeDamage(
(trapDamage(this.level) / trapHold(this.level)) * TICK,
game,
false,
this.body.position
);
}
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();
}
}
}
}
+38
View File
@@ -0,0 +1,38 @@
import * as THREE from 'three';
import { Weapon } from './Weapon';
import { Player } from './Player';
import { Trap } from './Trap';
import { GasCloud } from './GasCloud';
import { playSound } from './SoundManager';
import { trapCooldown, trapMax, gasCooldown } from './Skills';
export class Trapper extends Weapon {
skill1(groundPoint: THREE.Vector3, player: Player): void {
const level = player.game.skillSystem.getLevel('trap');
if (level <= 0) return;
if (this.cooldown1 > 0) return;
if (player.game.traps.length >= trapMax(level)) return;
this.COOLDOWN1_TIME = trapCooldown(level);
this.cooldown1 = this.COOLDOWN1_TIME;
const trap = new Trap(groundPoint.clone(), level);
player.game.scene.add(trap.body);
player.game.traps.push(trap);
playSound('spawn', 0.4);
}
skill2(groundPoint: THREE.Vector3, player: Player): void {
const level = player.game.skillSystem.getLevel('gascloud');
if (level <= 0) return;
if (this.cooldown2 > 0) return;
this.COOLDOWN2_TIME = gasCooldown(level);
this.cooldown2 = this.COOLDOWN2_TIME;
const cloud = new GasCloud(groundPoint.clone(), level);
player.game.scene.add(cloud.body);
player.game.gasClouds.push(cloud);
playSound('spawn', 0.4);
}
}