victor HF Staff Claude commited on
Commit
9e6ef9c
·
1 Parent(s): 3eeae2e

Refactor tower system and UI improvements

Browse files

- Refactored tower system into modular architecture
- Fixed sniper tower visual upgrades
- Fixed UI menu positioning to stay within screen bounds
- Fixed upgrade button triggering tower placement menu
- Improved event handling for UI interactions

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>

.gitignore ADDED
@@ -0,0 +1 @@
 
 
1
+ src/assets/music.mp3
src/config/gameConfig.js CHANGED
@@ -4,6 +4,14 @@ import * as THREE from "three";
4
  export const INITIAL_MONEY = 200;
5
  export const INITIAL_LIVES = 10;
6
 
 
 
 
 
 
 
 
 
7
  // Tower settings
8
  // Define multiple tower types, keeping the original as "basic"
9
  export const TOWER_TYPES = {
 
4
  export const INITIAL_MONEY = 200;
5
  export const INITIAL_LIVES = 10;
6
 
7
+ // Audio settings
8
+ export const AUDIO_CONFIG = {
9
+ musicVolume: 0.4, // Background music volume (0-1)
10
+ effectsVolume: 0.3, // Sound effects volume (0-1)
11
+ musicEnabled: true, // Enable/disable background music
12
+ effectsEnabled: true, // Enable/disable sound effects
13
+ };
14
+
15
  // Tower settings
16
  // Define multiple tower types, keeping the original as "basic"
17
  export const TOWER_TYPES = {
src/entities/Tower.js CHANGED
@@ -1,1140 +1 @@
1
- import * as THREE from "three";
2
- import { Projectile } from "./Projectile.js";
3
- import {
4
- UPGRADE_MAX_LEVEL,
5
- UPGRADE_START_COST,
6
- UPGRADE_COST_SCALE,
7
- UPGRADE_RANGE_SCALE,
8
- UPGRADE_RATE_SCALE,
9
- UPGRADE_DAMAGE_SCALE,
10
- SELL_REFUND_RATE,
11
- } from "../config/gameConfig.js";
12
-
13
- // Visual-only tuning: per-level head height increment starting at level 2
14
- // Values are in world units; no gameplay effect intended.
15
- const VISUAL_TOP_INCREMENT = 0.08; // +0.08 per level (from level 2)
16
- const VISUAL_TOP_CAP = 0.4; // cap total extra height
17
- // Electric-only: small vertical lift per upgrade level for the ball
18
- const ELECTRIC_BALL_LIFT_PER_LEVEL = 0.08; // gentle raise per level
19
- const ELECTRIC_BALL_LIFT_CAP = 0.35; // cap total lift
20
-
21
- export class Tower {
22
- constructor(pos, baseConfig, scene) {
23
- this.position = pos.clone();
24
- this.fireCooldown = 0;
25
- this.scene = scene;
26
-
27
- // type/config
28
- this.type = baseConfig.type || "basic";
29
- this.projectileEffect = baseConfig.projectileEffect || null;
30
-
31
- // Slow tower per-level slow settings (multiplier lower = stronger)
32
- // Applies only when this.type === "slow"
33
- this.slowMultByLevel = baseConfig.slowMultByLevel || [0.75, 0.7, 0.65];
34
- this.slowDuration = baseConfig.slowDuration || 1.5; // seconds
35
-
36
- // Electric-specific fields
37
- this.isElectric = this.type === "electric";
38
- if (this.isElectric) {
39
- // Configurable parameters for continuous DOT electric tower
40
- this.maxTargets = baseConfig.maxTargets ?? 4;
41
- this.damagePerSecond = baseConfig.damagePerSecond ?? 1;
42
- this.visualRefreshRate = baseConfig.visualRefreshRate ?? 60; // Hz
43
- this.visualRefreshInterval = 1 / Math.max(1, this.visualRefreshRate);
44
- this.arcFadeDuration = baseConfig.arcFadeDuration ?? 0.2; // seconds
45
- // Back-compat for any code expecting arcDurationMs (legacy fade driver)
46
- this.arcDurationMs = Math.max(
47
- 1,
48
- baseConfig.arcDurationMs ?? this.arcFadeDuration * 1000
49
- );
50
-
51
- this.arcStyle = {
52
- color: baseConfig.arc?.color ?? 0x9ad6ff,
53
- coreColor: baseConfig.arc?.coreColor ?? 0xe6fbff,
54
- thickness: baseConfig.arc?.thickness ?? 2,
55
- jitter: baseConfig.arc?.jitter ?? 0.25,
56
- segments: baseConfig.arc?.segments ?? 10,
57
- };
58
-
59
- // Targeting priority explicitly configurable for electric
60
- this.targetPriority =
61
- baseConfig.targetPriorityMode ||
62
- baseConfig.targetPriority ||
63
- "closestToExit";
64
-
65
- // Runtime state for continuous tracking/DOT and visuals
66
- this.trackedTargets = new Map(); // enemy -> { arc, fadeInTimer, visible, lastEnd }
67
- this.arcPool = []; // pooled { lineOuter, lineInner }
68
- this._visualAccumulator = 0; // accumulate dt for throttled visual refresh
69
-
70
- // Fade-out scheduler for pooled arcs (reuses legacy fade driver)
71
- this.activeArcs = [];
72
- }
73
-
74
- // upgradeable stats
75
- this.level = 1;
76
- this.baseRange = baseConfig.range;
77
- this.baseRate = baseConfig.fireRate;
78
- this.baseDamage = baseConfig.damage;
79
- this.range = this.baseRange;
80
- this.rate = this.baseRate;
81
- this.damage = this.baseDamage;
82
-
83
- // Initialize per-level slow state for slow tower
84
- if (this.type === "slow") {
85
- const idx = Math.max(
86
- 0,
87
- Math.min(this.slowMultByLevel.length - 1, this.level - 1)
88
- );
89
- // Create or extend projectileEffect to include slow at current level
90
- const effect = this.projectileEffect || {};
91
- this.projectileEffect = {
92
- ...effect,
93
- type: "slow",
94
- mult: this.slowMultByLevel[idx],
95
- duration: this.slowDuration,
96
- };
97
- }
98
- this.nextUpgradeCost = UPGRADE_START_COST;
99
- this.totalSpent = baseConfig.cost; // includes base cost for sell calculations
100
-
101
- // Sniper-specific fields
102
- this.isSniper = this.type === "sniper";
103
- this.aimTime = baseConfig.aimTime ?? 0; // seconds
104
- this.sniperProjectileSpeed = baseConfig.projectileSpeed ?? null;
105
- this.cancelThreshold = baseConfig.cancelThreshold ?? this.range;
106
- this.pierceChance = baseConfig.pierceChance ?? 0;
107
- this.targetPriority = baseConfig.targetPriority || "nearest";
108
- this.aiming = false;
109
- this.aimingTimer = 0;
110
- this.aimedTarget = null;
111
- this.laserLine = null;
112
-
113
- // Mesh
114
- const baseGeo = new THREE.CylinderGeometry(0.9, 1.2, 1, 12);
115
- const baseMat = new THREE.MeshStandardMaterial({
116
- // Pink base for slow tower; steel-ish for sniper; blue for basic
117
- color:
118
- this.type === "slow" ? 0xff69b4 : this.isSniper ? 0x6d6f73 : 0x3a97ff,
119
- metalness: this.isSniper ? 0.5 : 0.2,
120
- roughness: this.isSniper ? 0.35 : 0.6,
121
- });
122
- const base = new THREE.Mesh(baseGeo, baseMat);
123
- base.castShadow = true;
124
- base.receiveShadow = true;
125
- base.position.copy(this.position);
126
-
127
- // Head geometry by type
128
- let headGeo;
129
- if (this.type === "slow") {
130
- headGeo = new THREE.SphereGeometry(
131
- 0.55,
132
- 24,
133
- 16,
134
- 0,
135
- Math.PI * 2,
136
- Math.PI / 2,
137
- Math.PI / 2
138
- );
139
- } else if (this.isSniper) {
140
- // Triangular/pyramidal head: cone with 3 radial segments
141
- headGeo = new THREE.ConeGeometry(0.7, 0.9, 3);
142
- } else if (this.isElectric) {
143
- // Electric aesthetic: ball held by a bar over the same base
144
- // Build a thin vertical bar and a spherical "ball" emitter on top
145
- headGeo = new THREE.SphereGeometry(0.45, 20, 16);
146
- } else {
147
- headGeo = new THREE.BoxGeometry(0.8, 0.4, 0.8);
148
- }
149
-
150
- const headMat = new THREE.MeshStandardMaterial({
151
- color:
152
- this.type === "slow"
153
- ? 0xffb6c1
154
- : this.isSniper
155
- ? 0xb0bec5
156
- : this.isElectric
157
- ? 0x9ad6ff // brighter blue for electric ball
158
- : 0x90caf9,
159
- metalness: this.type === "slow" ? 0.15 : this.isSniper ? 0.35 : 0.18,
160
- roughness: this.type === "slow" ? 0.35 : this.isSniper ? 0.4 : 0.45,
161
- emissive: this.isSniper
162
- ? 0x330000
163
- : this.type === "slow"
164
- ? 0x4a0a2a
165
- : this.isElectric
166
- ? 0x153a6b
167
- : 0x000000,
168
- emissiveIntensity: this.isSniper
169
- ? 0.4
170
- : this.type === "slow"
171
- ? 0.4
172
- : this.isElectric
173
- ? 0.85
174
- : 0.6,
175
- side: THREE.DoubleSide,
176
- });
177
-
178
- // Assemble head group so electric can have bar + ball
179
- const head = new THREE.Mesh(headGeo, headMat);
180
- head.castShadow = true;
181
-
182
- if (this.type === "slow") {
183
- head.position.set(0, 0.8, 0);
184
- base.add(head);
185
- } else if (this.isSniper) {
186
- head.position.set(0, 0.95, 0);
187
- head.rotation.x = 0; // point up; we will yaw the base as usual
188
- base.add(head);
189
- } else if (this.isElectric) {
190
- // Create a mini-assembly: a thin bar and the ball on top
191
- const headGroup = new THREE.Group();
192
-
193
- // Bar: thin cylinder rising from base toward the ball
194
- const barHeight = 0.9;
195
- const barGeo = new THREE.CylinderGeometry(0.08, 0.08, barHeight, 16);
196
- const barMat = new THREE.MeshStandardMaterial({
197
- color: 0x1b1f24,
198
- metalness: 0.4,
199
- roughness: 0.6,
200
- });
201
- const bar = new THREE.Mesh(barGeo, barMat);
202
- bar.castShadow = true;
203
- bar.receiveShadow = true;
204
- // Position: center the bar; cylinder is centered, so raise by half height
205
- bar.position.set(0, 0.5 + barHeight * 0.5, 0);
206
-
207
- // Ball: sit atop the bar (baseline; apply per-level lift later)
208
- head.position.set(0, 0.5 + barHeight + 0.25, 0); // radius ~0.45; raise slightly
209
- // Slight scale for a rounder silhouette
210
- head.scale.set(1.0, 1.0, 1.0);
211
-
212
- headGroup.add(bar);
213
- headGroup.add(head);
214
-
215
- // Optionally add a subtle glow ring under the ball for readability
216
- const haloGeo = new THREE.TorusGeometry(0.38, 0.02, 8, 24);
217
- const haloMat = new THREE.MeshStandardMaterial({
218
- color: 0x80e1ff,
219
- emissive: 0x206a99,
220
- emissiveIntensity: 0.4,
221
- metalness: 0.2,
222
- roughness: 0.6,
223
- });
224
- const halo = new THREE.Mesh(haloGeo, haloMat);
225
- halo.rotation.x = Math.PI / 2;
226
- halo.position.set(0, head.position.y - 0.22, 0);
227
- halo.castShadow = false;
228
- halo.receiveShadow = false;
229
- headGroup.add(halo);
230
-
231
- // Attach to base
232
- base.add(headGroup);
233
-
234
- // For electric, define the emitter (headTopY) at ball center for better arc spawn
235
- // Keep original headTopY logic but update below after we add to base.
236
- } else {
237
- head.position.set(0, 0.8, 0);
238
- base.add(head);
239
- }
240
-
241
- // Range ring
242
- const ringGeo = new THREE.RingGeometry(this.range - 0.05, this.range, 48);
243
- const ringMat = new THREE.MeshBasicMaterial({
244
- // Improve visibility; give sniper a high-contrast cyan ring
245
- color:
246
- this.type === "slow" ? 0xff69b4 : this.isSniper ? 0x00ffff : 0x3a97ff,
247
- transparent: true,
248
- // Slightly higher opacity for clearer visibility
249
- opacity: this.isSniper ? 0.32 : 0.2,
250
- side: THREE.DoubleSide,
251
- // Avoid z-write so the ring isn't lost due to terrain depth artifacts
252
- depthWrite: false,
253
- depthTest: true,
254
- });
255
- const ring = new THREE.Mesh(ringGeo, ringMat);
256
- ring.rotation.x = -Math.PI / 2;
257
- // Lift slightly more to avoid any z-fighting with terrain across all types (incl. sniper)
258
- ring.position.y = 0.03;
259
- base.add(ring);
260
- // Explicitly ensure range ring is visible for all towers
261
- ring.visible = true;
262
-
263
- // Hover outline (thin torus hugging the base), initially hidden
264
- const outlineGeo = new THREE.TorusGeometry(1.05, 0.04, 8, 32);
265
- const outlineMat = new THREE.MeshBasicMaterial({
266
- color: 0xffff66,
267
- transparent: true,
268
- opacity: 0.85,
269
- depthWrite: false,
270
- });
271
- const outline = new THREE.Mesh(outlineGeo, outlineMat);
272
- outline.rotation.x = Math.PI / 2;
273
- outline.position.y = 0.52; // slightly above ground to avoid z-fight with base bottom
274
- outline.visible = false;
275
- outline.name = "tower_hover_outline";
276
- base.add(outline);
277
-
278
- this.mesh = base;
279
- this.baseMesh = base;
280
- this.headMesh = head;
281
- this.head = head;
282
- this.ring = ring;
283
- this.hoverOutline = outline;
284
- this.levelRing = null;
285
- // compute headTopY (slightly different for sniper head height)
286
- const headTopOffset = this.isSniper ? 0.55 : 0.4;
287
- this.headTopY = this.mesh.position.y + head.position.y + headTopOffset;
288
-
289
- // If electric, immediately apply level-based visual offset to lift the ball slightly
290
- if (this.isElectric) {
291
- // reuse the same function used on upgrade for consistent behavior
292
- this.applyVisualLevel();
293
- }
294
-
295
- scene.add(base);
296
- }
297
-
298
- get canUpgrade() {
299
- return this.level < UPGRADE_MAX_LEVEL;
300
- }
301
-
302
- getSellValue() {
303
- return Math.floor(this.totalSpent * SELL_REFUND_RATE);
304
- }
305
-
306
- upgrade() {
307
- if (!this.canUpgrade) return false;
308
-
309
- // Apply scaling
310
- this.level += 1;
311
- this.range *= UPGRADE_RANGE_SCALE;
312
- this.rate *= UPGRADE_RATE_SCALE;
313
- this.damage *= UPGRADE_DAMAGE_SCALE;
314
-
315
- // Update slow magnitude for slow tower per level
316
- if (this.type === "slow") {
317
- const idx = Math.max(
318
- 0,
319
- Math.min(this.slowMultByLevel.length - 1, this.level - 1)
320
- );
321
- const effect = this.projectileEffect || {};
322
- this.projectileEffect = {
323
- ...effect,
324
- type: "slow",
325
- mult: this.slowMultByLevel[idx],
326
- duration: this.slowDuration,
327
- };
328
- }
329
-
330
- // Sniper-specific upgrades
331
- if (this.isSniper) {
332
- // Reduce aim time per level (cap at 40% of original to avoid 0)
333
- const minAimTime = (this.aimTime ?? 0) * 0.4;
334
- this.aimTime = Math.max(minAimTime, (this.aimTime ?? 0) * 0.9);
335
- // Slightly increase pierce chance (cap small)
336
- this.pierceChance = Math.min(0.15, (this.pierceChance ?? 0) + 0.03);
337
- }
338
-
339
- // Rebuild range ring geometry
340
- const newGeo = new THREE.RingGeometry(this.range - 0.05, this.range, 48);
341
- this.ring.geometry.dispose();
342
- this.ring.geometry = newGeo;
343
-
344
- // Cost bookkeeping
345
- this.totalSpent += this.nextUpgradeCost;
346
- this.nextUpgradeCost = Math.round(
347
- this.nextUpgradeCost * UPGRADE_COST_SCALE
348
- );
349
-
350
- // Apply visual changes
351
- this.applyVisualLevel();
352
-
353
- return true;
354
- }
355
-
356
- applyVisualLevel() {
357
- const lvl = this.level;
358
- const head = this.headMesh;
359
- if (!head) return;
360
-
361
- const baseMat = this.baseMesh?.material;
362
- const headMat = head.material;
363
-
364
- // Remove previous ring if any
365
- if (this.levelRing) {
366
- this.scene.remove(this.levelRing);
367
- this.levelRing.geometry.dispose();
368
- if (this.levelRing.material?.dispose) this.levelRing.material.dispose();
369
- this.levelRing = null;
370
- }
371
-
372
- // Compute visual-only extra height based on level (starts at level 2)
373
- const extraRaw = Math.max(0, (lvl - 1) * VISUAL_TOP_INCREMENT);
374
- const visualExtra = Math.min(VISUAL_TOP_CAP, extraRaw);
375
-
376
- if (lvl <= 1) {
377
- // Default look
378
- if (baseMat) {
379
- baseMat.color?.set?.(this.type === "slow" ? 0xff69b4 : 0x5c6bc0);
380
- baseMat.emissive?.set?.(0x000000);
381
- baseMat.emissiveIntensity = 0.0;
382
- }
383
-
384
- if (this.type === "slow") {
385
- // Keep dome proportions; baseline dome
386
- head.scale.set(1, 1, 1);
387
- head.position.y = 0.8 + visualExtra;
388
- this.headTopY =
389
- (this.mesh?.position.y ?? 0.25) + head.position.y + 0.55;
390
- } else if (this.isElectric) {
391
- // Electric level 1: keep ball shape; apply per-level lift (starts at 0)
392
- const liftRaw = Math.max(0, (lvl - 1) * ELECTRIC_BALL_LIFT_PER_LEVEL);
393
- const lift = Math.min(ELECTRIC_BALL_LIFT_CAP, liftRaw);
394
- // Base y for electric ball at level 1 is determined in constructor; adjust relatively
395
- head.position.y = head.position.y + lift;
396
- this.headTopY =
397
- (this.mesh?.position.y ?? 0.25) + head.position.y + 0.45;
398
- } else {
399
- // Box head baseline
400
- head.scale.set(1, 1, 1);
401
- // Raise slightly with visualExtra even at level 1 if any (should be 0)
402
- head.position.y = 0.65 + visualExtra;
403
- this.headTopY = (this.mesh?.position.y ?? 0.25) + head.position.y + 0.4;
404
- }
405
-
406
- headMat.color?.set?.(this.type === "slow" ? 0xffb6c1 : 0x90caf9);
407
- headMat.emissive?.set?.(0x4a0a2a);
408
- headMat.emissiveIntensity = 0.2;
409
- } else {
410
- // Level 2+ look
411
- if (baseMat) {
412
- baseMat.color?.set?.(this.type === "slow" ? 0xff5ea8 : 0x6f7bd6);
413
- baseMat.emissive?.set?.(0x2a0a1a);
414
- baseMat.emissiveIntensity = 0.08;
415
- }
416
-
417
- if (this.type === "slow") {
418
- // Slightly larger dome; raise by visualExtra
419
- head.scale.set(1.1, 1.15, 1.1);
420
- head.position.y = 0.9 + visualExtra;
421
- this.headTopY = (this.mesh?.position.y ?? 0.25) + head.position.y + 0.6;
422
- } else if (this.isElectric) {
423
- // Electric level 2+: keep ball, lift a bit per level
424
- const liftRaw = Math.max(0, (lvl - 1) * ELECTRIC_BALL_LIFT_PER_LEVEL);
425
- const lift = Math.min(ELECTRIC_BALL_LIFT_CAP, liftRaw);
426
- head.scale.set(1.0, 1.0, 1.0);
427
- // Baseline in constructor; add visualExtra only for non-electric, so use lift only here
428
- head.position.y = head.position.y + lift;
429
- this.headTopY =
430
- (this.mesh?.position.y ?? 0.25) + head.position.y + 0.45;
431
- } else {
432
- // Taller box; raise by visualExtra
433
- head.scale.set(1, 2, 1);
434
- head.position.y = 0.65 + 0.4 + visualExtra;
435
- this.headTopY = (this.mesh?.position.y ?? 0.25) + head.position.y + 0.8;
436
- }
437
-
438
- headMat.color?.set?.(this.type === "slow" ? 0xffc6d9 : 0xa5d6ff);
439
- headMat.emissive?.set?.(0x9a135a);
440
- headMat.emissiveIntensity = 0.35;
441
-
442
- // Optional thin ring on top
443
- const ringGeom = new THREE.TorusGeometry(0.45, 0.035, 8, 24);
444
- const ringMat = new THREE.MeshStandardMaterial({
445
- color: this.type === "slow" ? 0xff8fc2 : 0x3aa6ff,
446
- emissive: 0xe01a6b,
447
- emissiveIntensity: 0.55,
448
- metalness: 0.3,
449
- roughness: 0.45,
450
- });
451
- const ring = new THREE.Mesh(ringGeom, ringMat);
452
- ring.castShadow = false;
453
- ring.receiveShadow = false;
454
-
455
- const topY = this.headTopY ?? head.position.y + 0.8;
456
- ring.position.set(
457
- this.mesh.position.x,
458
- topY + 0.02,
459
- this.mesh.position.z
460
- );
461
- ring.rotation.x = Math.PI / 2;
462
- ring.name = "tower_level_ring";
463
-
464
- this.levelRing = ring;
465
- this.scene.add(ring);
466
- }
467
- }
468
-
469
- // -------- Targeting helpers (shared) --------
470
-
471
- // Default nearest-within-range or closestToExit for sniper/electric
472
- findTarget(enemies) {
473
- if (
474
- (this.isSniper || this.isElectric) &&
475
- this.targetPriority === "closestToExit"
476
- ) {
477
- return this.findTargetClosestToExit(enemies);
478
- }
479
-
480
- // default: nearest within range
481
- let nearest = null;
482
- let nearestDistSq = Infinity;
483
-
484
- for (const e of enemies) {
485
- const dSq = e.mesh.position.distanceToSquared(this.position);
486
- if (dSq <= this.range * this.range && dSq < nearestDistSq) {
487
- nearest = e;
488
- nearestDistSq = dSq;
489
- }
490
- }
491
-
492
- return nearest;
493
- }
494
-
495
- // Electric: return ALL targets in range, ordered by priority (no cap)
496
- findMultipleTargets(enemies) {
497
- const rangeSq = this.range * this.range;
498
- const inRange = [];
499
- for (const e of enemies) {
500
- const dSq = e.mesh.position.distanceToSquared(this.position);
501
- if (dSq <= rangeSq) inRange.push(e);
502
- }
503
- if (inRange.length === 0) return [];
504
-
505
- if (this.targetPriority === "closestToExit") {
506
- const towerPos = this.position;
507
- inRange.sort((a, b) => {
508
- const segA = a.currentSeg ?? 0;
509
- const segB = b.currentSeg ?? 0;
510
- if (segA !== segB) return segB - segA; // higher first
511
- const remA = a.target
512
- ? a.target.distanceTo(a.position ?? a.mesh.position)
513
- : Infinity;
514
- const remB = b.target
515
- ? b.target.distanceTo(b.position ?? b.mesh.position)
516
- : Infinity;
517
- if (remA !== remB) return remA - remB; // shorter first
518
- const da = (a.mesh?.position || a.position).distanceTo(towerPos);
519
- const db = (b.mesh?.position || b.position).distanceTo(towerPos);
520
- return da - db;
521
- });
522
- } else {
523
- // nearest by distance to tower
524
- const towerPos = this.position;
525
- inRange.sort((a, b) => {
526
- const da = (a.mesh?.position || a.position).distanceTo(towerPos);
527
- const db = (b.mesh?.position || b.position).distanceTo(towerPos);
528
- return da - db;
529
- });
530
- }
531
-
532
- return inRange; // No limiting: attack all in range
533
- }
534
-
535
- // Sniper priority: higher currentSeg first, then remaining distance to enemy.target, then distance to tower
536
- findTargetClosestToExit(enemies) {
537
- const inRange = [];
538
- const rangeSq = this.range * this.range;
539
- for (const e of enemies) {
540
- const dSq = e.mesh.position.distanceToSquared(this.position);
541
- if (dSq <= rangeSq) {
542
- inRange.push(e);
543
- }
544
- }
545
- if (inRange.length === 0) return null;
546
-
547
- const towerPos = this.position;
548
- inRange.sort((a, b) => {
549
- const segA = a.currentSeg ?? 0;
550
- const segB = b.currentSeg ?? 0;
551
- if (segA !== segB) return segB - segA; // higher first
552
-
553
- // remaining distance to current segment target
554
- const remA = a.target
555
- ? a.target.distanceTo(a.position ?? a.mesh.position)
556
- : Infinity;
557
- const remB = b.target
558
- ? b.target.distanceTo(b.position ?? b.mesh.position)
559
- : Infinity;
560
- if (remA !== remB) return remA - remB; // shorter first
561
-
562
- // tie-breaker: distance to tower
563
- const da = (a.mesh?.position || a.position).distanceTo(towerPos);
564
- const db = (b.mesh?.position || b.position).distanceTo(towerPos);
565
- return da - db;
566
- });
567
-
568
- return inRange[0] || null;
569
- }
570
-
571
- // -------- Visual helpers (laser and electric arc creation/fade) --------
572
-
573
- createLaser(start, end) {
574
- const points = [start.clone(), end.clone()];
575
- const geometry = new THREE.BufferGeometry().setFromPoints(points);
576
- const material = new THREE.LineBasicMaterial({
577
- color: 0xff3b30,
578
- transparent: true,
579
- opacity: 0.9,
580
- linewidth: 2,
581
- });
582
- const line = new THREE.Line(geometry, material);
583
- // raise slightly to avoid z-fighting with terrain
584
- line.position.y += 0.01;
585
- this.scene.add(line);
586
- return line;
587
- }
588
-
589
- // Electric: create a jagged arc polyline with jitter (outer + inner)
590
- createElectricArc(start, end) {
591
- const style = this.arcStyle || {};
592
- const segs = Math.max(2, style.segments ?? 10);
593
- const jitter = style.jitter ?? 0.25;
594
-
595
- const dir = new THREE.Vector3().subVectors(end, start);
596
- const len = dir.length();
597
- if (len < 1e-4) dir.set(0, 0, 1);
598
- else dir.normalize();
599
-
600
- // Build perpendicular basis for 3D jitter
601
- const up = new THREE.Vector3(0, 1, 0);
602
- let right = new THREE.Vector3().crossVectors(dir, up);
603
- if (right.lengthSq() < 1e-6) {
604
- right = new THREE.Vector3(1, 0, 0); // fallback if parallel
605
- } else {
606
- right.normalize();
607
- }
608
- const binorm = new THREE.Vector3().crossVectors(dir, right).normalize();
609
-
610
- const points = [];
611
- for (let i = 0; i <= segs; i++) {
612
- const t = i / segs;
613
- const base = new THREE.Vector3()
614
- .copy(start)
615
- .addScaledVector(dir, len * t);
616
- const amp = jitter * (1 - Math.abs(0.5 - t) * 2); // less jitter near ends
617
- const offR = (Math.random() * 2 - 1) * amp;
618
- const offB = (Math.random() * 2 - 1) * amp;
619
- base.addScaledVector(right, offR).addScaledVector(binorm, offB);
620
- // slight upward lift to avoid z-fighting
621
- base.y += 0.01;
622
- points.push(base);
623
- }
624
-
625
- const geometry = new THREE.BufferGeometry().setFromPoints(points);
626
- const matOuter = new THREE.LineBasicMaterial({
627
- color: style.color ?? 0x9ad6ff,
628
- transparent: true,
629
- opacity: 0.5,
630
- linewidth: (style.thickness ?? 2) * 1.8,
631
- depthWrite: false,
632
- });
633
- const matInner = new THREE.LineBasicMaterial({
634
- color: style.coreColor ?? 0xe6fbff,
635
- transparent: true,
636
- opacity: 0.95,
637
- linewidth: style.thickness ?? 2,
638
- depthWrite: false,
639
- });
640
-
641
- const lineOuter = new THREE.Line(geometry, matOuter);
642
- const lineInner = new THREE.Line(geometry.clone(), matInner);
643
-
644
- this.scene.add(lineOuter);
645
- this.scene.add(lineInner);
646
-
647
- lineOuter.visible = true;
648
- lineInner.visible = true;
649
-
650
- return {
651
- lineOuter,
652
- lineInner,
653
- createdAt: performance.now(),
654
- duration: this.arcDurationMs ?? 120,
655
- };
656
- }
657
-
658
- // Fade driver for pooled arcs scheduled for fade-out
659
- updateElectricArcs(nowMs = performance.now()) {
660
- if (!this.activeArcs || this.activeArcs.length === 0) return;
661
- const remain = [];
662
- for (const arc of this.activeArcs) {
663
- const t = Math.max(
664
- 0,
665
- Math.min(1, (nowMs - arc.createdAt) / arc.duration)
666
- );
667
- const fade = 1.0 - t;
668
- if (arc.lineOuter?.material) {
669
- arc.lineOuter.material.opacity = 0.5 * fade;
670
- arc.lineOuter.material.needsUpdate = true;
671
- }
672
- if (arc.lineInner?.material) {
673
- arc.lineInner.material.opacity = 0.95 * fade;
674
- arc.lineInner.material.needsUpdate = true;
675
- }
676
- if (t < 1) {
677
- remain.push(arc);
678
- } else {
679
- // finished fading: return to pool (keep geometry/material for reuse)
680
- if (arc.lineOuter || arc.lineInner) {
681
- this.arcPool ||= [];
682
- if (arc.lineOuter) arc.lineOuter.visible = false;
683
- if (arc.lineInner) arc.lineInner.visible = false;
684
- this.arcPool.push({
685
- lineOuter: arc.lineOuter,
686
- lineInner: arc.lineInner,
687
- });
688
- }
689
- }
690
- }
691
- this.activeArcs = remain;
692
- }
693
-
694
- updateLaser(line, start, end) {
695
- const positions = line.geometry.attributes.position;
696
- positions.setXYZ(0, start.x, start.y, start.z);
697
- positions.setXYZ(1, end.x, end.y, end.z);
698
- positions.needsUpdate = true;
699
- }
700
-
701
- removeLaser() {
702
- if (this.laserLine) {
703
- this.scene.remove(this.laserLine);
704
- if (this.laserLine.geometry) this.laserLine.geometry.dispose();
705
- if (this.laserLine.material?.dispose) this.laserLine.material.dispose();
706
- this.laserLine = null;
707
- }
708
- }
709
-
710
- // -------- Electric continuous DOT system (targeting, DOT, visuals) --------
711
-
712
- // Acquire or refresh the top-N targets deterministically
713
- _refreshElectricTargets(enemies) {
714
- const desired = this.findMultipleTargets(enemies, this.maxTargets || 4);
715
- const prev = this.trackedTargets || new Map();
716
-
717
- // Build sets for enter/exit detection
718
- const desiredSet = new Set(desired);
719
- const currentSet = new Set(prev.keys());
720
-
721
- // Exits (stop DOT, schedule fade, remove from map)
722
- for (const e of currentSet) {
723
- if (!desiredSet.has(e)) {
724
- const info = prev.get(e);
725
- if (info?.arc) {
726
- const now = performance.now();
727
- const durMs = Math.max(1, (this.arcFadeDuration || 0.2) * 1000);
728
- this.activeArcs ||= [];
729
- this.activeArcs.push({
730
- lineOuter: info.arc.lineOuter,
731
- lineInner: info.arc.lineInner,
732
- createdAt: now,
733
- duration: durMs,
734
- });
735
- }
736
- prev.delete(e);
737
- }
738
- }
739
-
740
- // Entries (start tracking, will create arc on visual update)
741
- for (const e of desired) {
742
- if (!prev.has(e)) {
743
- prev.set(e, {
744
- arc: null,
745
- fadeInTimer: 0,
746
- lastEnd: null,
747
- visible: false,
748
- });
749
- }
750
- }
751
-
752
- this.trackedTargets = prev;
753
- return desired;
754
- }
755
-
756
- // Acquire an arc from pool or create a new one
757
- _getArcInstance(start, end) {
758
- if (this.arcPool && this.arcPool.length > 0) {
759
- const pooled = this.arcPool.pop();
760
- this._updateArcGeometry(pooled, start, end, true);
761
- if (pooled.lineOuter) pooled.lineOuter.visible = true;
762
- if (pooled.lineInner) pooled.lineInner.visible = true;
763
- return {
764
- lineOuter: pooled.lineOuter,
765
- lineInner: pooled.lineInner,
766
- createdAt: performance.now(),
767
- duration: (this.arcFadeDuration || 0.2) * 1000,
768
- };
769
- }
770
- return this.createElectricArc(start, end);
771
- }
772
-
773
- // Update arc lines to follow moving target, optionally rebuild points
774
- _updateArcGeometry(arc, start, end, rebuild = true) {
775
- if (!arc?.lineOuter || !arc?.lineInner) return;
776
-
777
- if (rebuild) {
778
- // Rebuild jittered polyline to add life to the arc
779
- const style = this.arcStyle || {};
780
- const segs = Math.max(2, style.segments ?? 10);
781
- const jitter = style.jitter ?? 0.25;
782
-
783
- const dir = new THREE.Vector3().subVectors(end, start);
784
- const len = dir.length();
785
- if (len < 1e-4) dir.set(0, 0, 1);
786
- else dir.normalize();
787
-
788
- const up = new THREE.Vector3(0, 1, 0);
789
- let right = new THREE.Vector3().crossVectors(dir, up);
790
- if (right.lengthSq() < 1e-6) right = new THREE.Vector3(1, 0, 0);
791
- else right.normalize();
792
- const binorm = new THREE.Vector3().crossVectors(dir, right).normalize();
793
-
794
- const points = [];
795
- for (let i = 0; i <= segs; i++) {
796
- const t = i / segs;
797
- const base = new THREE.Vector3()
798
- .copy(start)
799
- .addScaledVector(dir, len * t);
800
- const amp = jitter * (1 - Math.abs(0.5 - t) * 2);
801
- const offR = (Math.random() * 2 - 1) * amp;
802
- const offB = (Math.random() * 2 - 1) * amp;
803
- base.addScaledVector(right, offR).addScaledVector(binorm, offB);
804
- base.y += 0.01;
805
- points.push(base);
806
- }
807
-
808
- const newGeo = new THREE.BufferGeometry().setFromPoints(points);
809
- const oldOuter = arc.lineOuter.geometry;
810
- const oldInner = arc.lineInner.geometry;
811
- arc.lineOuter.geometry = newGeo;
812
- arc.lineInner.geometry = newGeo.clone();
813
- oldOuter?.dispose?.();
814
- oldInner?.dispose?.();
815
- } else {
816
- // simple 2-point update (not used with jittered arcs)
817
- const positions = arc.lineOuter.geometry.attributes.position;
818
- positions.setXYZ(0, start.x, start.y, start.z);
819
- positions.setXYZ(positions.count - 1, end.x, end.y, end.z);
820
- positions.needsUpdate = true;
821
- const positions2 = arc.lineInner.geometry.attributes.position;
822
- positions2.setXYZ(0, start.x, start.y, start.z);
823
- positions2.setXYZ(positions2.count - 1, end.x, end.y, end.z);
824
- positions2.needsUpdate = true;
825
- }
826
- }
827
-
828
- // Electric per-frame update: targets, DOT, and visuals
829
- updateElectric(dt, enemies) {
830
- // Refresh target list first to avoid DOT on out-of-range
831
- const current = this._refreshElectricTargets(enemies);
832
- if (!current.length) {
833
- // no targets: fades scheduled separately; nothing to do
834
- return;
835
- }
836
-
837
- // Aim toward primary target for coherence
838
- const primary = current[0];
839
- if (primary?.mesh?.position) {
840
- const dir = new THREE.Vector3().subVectors(
841
- primary.mesh.position,
842
- this.position
843
- );
844
- const yaw = Math.atan2(dir.x, dir.z);
845
- this.mesh.rotation.y = yaw;
846
- }
847
-
848
- // Apply DPS per tracked target, frame-rate independent
849
- const dps = this.damagePerSecond ?? 1;
850
- for (const enemy of current) {
851
- // guard against removed/destroyed enemies
852
- if (!enemy || enemy.isDead?.()) continue;
853
- enemy.takeDamage?.(dps * dt);
854
- }
855
-
856
- // Visual follow with throttled refresh
857
- this._visualAccumulator += dt;
858
- const doRefresh = this._visualAccumulator >= this.visualRefreshInterval;
859
- if (doRefresh) this._visualAccumulator = 0;
860
-
861
- const start = this.position
862
- .clone()
863
- .add(new THREE.Vector3(0, this.headTopY ?? 0.9, 0));
864
- for (const enemy of current) {
865
- const info = this.trackedTargets.get(enemy);
866
- const end = (enemy.mesh?.position || enemy.position)?.clone?.();
867
- if (!end) continue;
868
-
869
- // create or update arc
870
- if (!info.arc) {
871
- info.arc = this._getArcInstance(start, end);
872
- // fade in
873
- if (info.arc?.lineOuter?.material)
874
- info.arc.lineOuter.material.opacity = 0.0;
875
- if (info.arc?.lineInner?.material)
876
- info.arc.lineInner.material.opacity = 0.0;
877
- info.fadeInTimer = this.arcFadeDuration ?? 0.2;
878
- }
879
- // update position occasionally to reduce cost
880
- if (doRefresh) this._updateArcGeometry(info.arc, start, end, true);
881
-
882
- // fade in progression
883
- if (typeof info.fadeInTimer === "number" && info.fadeInTimer > 0) {
884
- info.fadeInTimer = Math.max(0, info.fadeInTimer - dt);
885
- const denom = this.arcFadeDuration || 0.2;
886
- const t = denom > 0 ? 1 - info.fadeInTimer / denom : 1;
887
- const outer = info.arc.lineOuter ? info.arc.lineOuter.material : null;
888
- const inner = info.arc.lineInner ? info.arc.lineInner.material : null;
889
- if (outer) {
890
- outer.opacity = 0.5 * Math.min(1, t);
891
- outer.needsUpdate = true;
892
- }
893
- if (inner) {
894
- inner.opacity = 0.95 * Math.min(1, t);
895
- inner.needsUpdate = true;
896
- }
897
- }
898
-
899
- info.visible = true;
900
- info.lastEnd = end;
901
- }
902
- }
903
-
904
- // -------- Audio hooks (safe no-ops if not wired) --------
905
- playAimingTone() {
906
- if (
907
- typeof window !== "undefined" &&
908
- window.UIManager &&
909
- window.UIManager.playAimingTone
910
- ) {
911
- try {
912
- window.UIManager.playAimingTone(this);
913
- } catch {}
914
- }
915
- }
916
- stopAimingTone() {
917
- if (
918
- typeof window !== "undefined" &&
919
- window.UIManager &&
920
- window.UIManager.stopAimingTone
921
- ) {
922
- try {
923
- window.UIManager.stopAimingTone(this);
924
- } catch {}
925
- }
926
- }
927
-
928
- playFireCrack() {
929
- if (
930
- typeof window !== "undefined" &&
931
- window.UIManager &&
932
- window.UIManager.playFireCrack
933
- ) {
934
- try {
935
- window.UIManager.playFireCrack(this);
936
- } catch {}
937
- }
938
- }
939
-
940
- // -------- Main per-frame firing/logic entry --------
941
- tryFire(dt, enemies, projectiles, projectileSpeed) {
942
- this.fireCooldown -= dt;
943
-
944
- if (this.isSniper) {
945
- // If currently aiming, update aim
946
- if (this.aiming) {
947
- const t = this.aimedTarget;
948
- const targetPos = t?.mesh?.position || t?.position;
949
- const alive = t && !t.isDead();
950
- const within =
951
- alive &&
952
- targetPos?.distanceToSquared(this.position) <=
953
- this.cancelThreshold * this.cancelThreshold;
954
-
955
- if (!alive || !within) {
956
- // cancel aiming
957
- this.removeLaser();
958
- this.stopAimingTone();
959
- this.aiming = false;
960
- this.aimedTarget = null;
961
- } else {
962
- // rotate toward target
963
- const dir = new THREE.Vector3().subVectors(targetPos, this.position);
964
- const yaw = Math.atan2(dir.x, dir.z);
965
- this.mesh.rotation.y = yaw;
966
-
967
- // update laser
968
- const start = this.position
969
- .clone()
970
- .add(new THREE.Vector3(0, this.headTopY, 0));
971
- const end = targetPos.clone();
972
- if (this.laserLine) this.updateLaser(this.laserLine, start, end);
973
-
974
- // countdown
975
- this.aimingTimer -= dt;
976
- if (this.aimingTimer <= 0) {
977
- // fire a single dart
978
- const spawnY =
979
- typeof this.headTopY === "number" ? this.headTopY - 0.1 : 0.9;
980
- const proj = new Projectile(
981
- this.position.clone().add(new THREE.Vector3(0, spawnY, 0)),
982
- t,
983
- this.sniperProjectileSpeed ?? projectileSpeed,
984
- this.scene,
985
- null
986
- );
987
- proj.damage = this.damage;
988
- projectiles.push(proj);
989
-
990
- // cleanup
991
- this.removeLaser();
992
- this.stopAimingTone();
993
- this.playFireCrack();
994
-
995
- // cooldown
996
- this.fireCooldown = 1 / this.rate;
997
-
998
- // exit aiming
999
- this.aiming = false;
1000
- this.aimedTarget = null;
1001
- }
1002
- }
1003
- return; // handled aiming
1004
- }
1005
-
1006
- // Not aiming: respect cooldown, then acquire and start aiming
1007
- if (this.fireCooldown > 0) return;
1008
-
1009
- const target = this.findTarget(enemies);
1010
- if (!target) return;
1011
-
1012
- // rotate immediately to target
1013
- const dir = new THREE.Vector3().subVectors(
1014
- target.mesh.position,
1015
- this.position
1016
- );
1017
- const yaw = Math.atan2(dir.x, dir.z);
1018
- this.mesh.rotation.y = yaw;
1019
-
1020
- // begin aiming
1021
- this.aimedTarget = target;
1022
- this.aiming = true;
1023
- this.aimingTimer = Math.max(0.01, this.aimTime || 0.01);
1024
-
1025
- const start = this.position
1026
- .clone()
1027
- .add(new THREE.Vector3(0, this.headTopY, 0));
1028
- const end = target.mesh.position.clone();
1029
- this.laserLine = this.createLaser(start, end);
1030
- this.playAimingTone();
1031
-
1032
- return;
1033
- }
1034
-
1035
- // Electric: continuous DOT and visual tracking every frame
1036
- if (this.isElectric) {
1037
- this.updateElectric(dt, enemies);
1038
- // advance any scheduled arc fades
1039
- this.updateElectricArcs();
1040
- return;
1041
- }
1042
-
1043
- // Default non-sniper behavior (cooldown-based projectile)
1044
- if (this.fireCooldown > 0) return;
1045
-
1046
- const target = this.findTarget(enemies);
1047
- if (!target) return;
1048
-
1049
- // Aim head towards target
1050
- const dir = new THREE.Vector3().subVectors(
1051
- target.mesh.position,
1052
- this.position
1053
- );
1054
- const yaw = Math.atan2(dir.x, dir.z);
1055
- this.mesh.rotation.y = yaw;
1056
-
1057
- // Fire
1058
- this.fireCooldown = 1 / this.rate;
1059
-
1060
- // Create projectile (spawn just below headTopY for better alignment)
1061
- const spawnY =
1062
- typeof this.headTopY === "number" ? this.headTopY - 0.1 : 0.9;
1063
- const proj = new Projectile(
1064
- this.position.clone().add(new THREE.Vector3(0, spawnY, 0)),
1065
- target,
1066
- projectileSpeed,
1067
- this.scene,
1068
- this.projectileEffect || null
1069
- );
1070
- proj.damage = this.damage;
1071
- projectiles.push(proj);
1072
- } // close tryFire
1073
-
1074
- // -------- Selection/Hover UI --------
1075
- setSelected(selected) {
1076
- this.selected = !!selected;
1077
- if (this.hoverOutline) {
1078
- // Selection forces outline visible
1079
- this.hoverOutline.visible = this.selected || !!this.hovered;
1080
- // Optional: make selection a bit brighter
1081
- this.hoverOutline.material.opacity = this.selected ? 0.95 : 0.85;
1082
- }
1083
- }
1084
-
1085
- // Hover toggle (kept separate from selection)
1086
- setHovered(hovered) {
1087
- this.hovered = !!hovered;
1088
- if (this.hoverOutline) {
1089
- // Only hide if not selected
1090
- this.hoverOutline.visible = this.selected || this.hovered;
1091
- }
1092
- }
1093
-
1094
- // -------- Cleanup --------
1095
- destroy() {
1096
- // cleanup sniper visual if any
1097
- this.removeLaser?.();
1098
-
1099
- const disposeArc = (arcObj) => {
1100
- if (!arcObj) return;
1101
- const { lineOuter, lineInner } = arcObj;
1102
- if (lineOuter) {
1103
- this.scene.remove(lineOuter);
1104
- lineOuter.geometry?.dispose?.();
1105
- lineOuter.material?.dispose?.();
1106
- }
1107
- if (lineInner) {
1108
- this.scene.remove(lineInner);
1109
- lineInner.geometry?.dispose?.();
1110
- lineInner.material?.dispose?.();
1111
- }
1112
- };
1113
-
1114
- // cleanup electric arcs and pool if any
1115
- if (this.activeArcs && this.activeArcs.length) {
1116
- for (const arc of this.activeArcs) disposeArc(arc);
1117
- this.activeArcs = [];
1118
- }
1119
-
1120
- if (this.trackedTargets && this.trackedTargets.size) {
1121
- for (const info of this.trackedTargets.values()) {
1122
- if (info.arc) disposeArc(info.arc);
1123
- }
1124
- this.trackedTargets.clear();
1125
- }
1126
-
1127
- if (this.arcPool && this.arcPool.length) {
1128
- for (const pooled of this.arcPool) disposeArc(pooled);
1129
- this.arcPool.length = 0;
1130
- }
1131
-
1132
- if (this.levelRing) {
1133
- this.scene.remove(this.levelRing);
1134
- this.levelRing.geometry.dispose();
1135
- if (this.levelRing.material?.dispose) this.levelRing.material.dispose();
1136
- this.levelRing = null;
1137
- }
1138
- this.scene.remove(this.mesh);
1139
- }
1140
- }
 
1
+ export { Tower } from './towers/Tower.js';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/entities/towers/Tower.js ADDED
@@ -0,0 +1,285 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as THREE from "three";
2
+ import {
3
+ UPGRADE_MAX_LEVEL,
4
+ UPGRADE_START_COST,
5
+ UPGRADE_COST_SCALE,
6
+ UPGRADE_RANGE_SCALE,
7
+ UPGRADE_RATE_SCALE,
8
+ UPGRADE_DAMAGE_SCALE,
9
+ SELL_REFUND_RATE,
10
+ } from "../../config/gameConfig.js";
11
+ import { KINDS } from "./kinds/index.js";
12
+
13
+ export class Tower {
14
+ constructor(pos, baseConfig, scene) {
15
+ this.position = pos.clone();
16
+ this.fireCooldown = 0;
17
+ this.scene = scene;
18
+
19
+ this.type = baseConfig.type || "basic";
20
+ this.projectileEffect = baseConfig.projectileEffect || null;
21
+
22
+ this.slowMultByLevel = baseConfig.slowMultByLevel || [0.75, 0.7, 0.65];
23
+ this.slowDuration = baseConfig.slowDuration || 1.5;
24
+
25
+ this.isElectric = this.type === "electric";
26
+ if (this.isElectric) {
27
+ this.maxTargets = baseConfig.maxTargets ?? 4;
28
+ this.damagePerSecond = baseConfig.damagePerSecond ?? 1;
29
+ this.visualRefreshRate = baseConfig.visualRefreshRate ?? 60;
30
+ this.visualRefreshInterval = 1 / Math.max(1, this.visualRefreshRate);
31
+ this.arcFadeDuration = baseConfig.arcFadeDuration ?? 0.2;
32
+ this.arcDurationMs = Math.max(
33
+ 1,
34
+ baseConfig.arcDurationMs ?? this.arcFadeDuration * 1000
35
+ );
36
+
37
+ this.arcStyle = {
38
+ color: baseConfig.arc?.color ?? 0x9ad6ff,
39
+ coreColor: baseConfig.arc?.coreColor ?? 0xe6fbff,
40
+ thickness: baseConfig.arc?.thickness ?? 2,
41
+ jitter: baseConfig.arc?.jitter ?? 0.25,
42
+ segments: baseConfig.arc?.segments ?? 10,
43
+ };
44
+
45
+ this.targetPriority =
46
+ baseConfig.targetPriorityMode ||
47
+ baseConfig.targetPriority ||
48
+ "closestToExit";
49
+ }
50
+
51
+ this.level = 1;
52
+ this.baseRange = baseConfig.range;
53
+ this.baseRate = baseConfig.fireRate;
54
+ this.baseDamage = baseConfig.damage;
55
+ this.range = this.baseRange;
56
+ this.rate = this.baseRate;
57
+ this.damage = this.baseDamage;
58
+
59
+ if (this.type === "slow") {
60
+ const idx = Math.max(
61
+ 0,
62
+ Math.min(this.slowMultByLevel.length - 1, this.level - 1)
63
+ );
64
+ const effect = this.projectileEffect || {};
65
+ this.projectileEffect = {
66
+ ...effect,
67
+ type: "slow",
68
+ mult: this.slowMultByLevel[idx],
69
+ duration: this.slowDuration,
70
+ };
71
+ }
72
+ this.nextUpgradeCost = UPGRADE_START_COST;
73
+ this.totalSpent = baseConfig.cost;
74
+
75
+ this.isSniper = this.type === "sniper";
76
+ this.aimTime = baseConfig.aimTime ?? 0;
77
+ this.sniperProjectileSpeed = baseConfig.projectileSpeed ?? null;
78
+ this.cancelThreshold = baseConfig.cancelThreshold ?? this.range;
79
+ this.pierceChance = baseConfig.pierceChance ?? 0;
80
+ this.targetPriority = baseConfig.targetPriority || (this.isSniper ? "closestToExit" : "nearest");
81
+ this.aiming = false;
82
+ this.aimingTimer = 0;
83
+ this.aimedTarget = null;
84
+ this.laserLine = null;
85
+
86
+ const baseGeo = new THREE.CylinderGeometry(0.9, 1.2, 1, 12);
87
+ const baseMat = new THREE.MeshStandardMaterial({
88
+ color:
89
+ this.type === "slow" ? 0xff69b4 : this.isSniper ? 0x6d6f73 : 0x3a97ff,
90
+ metalness: this.isSniper ? 0.5 : 0.2,
91
+ roughness: this.isSniper ? 0.35 : 0.6,
92
+ });
93
+ const base = new THREE.Mesh(baseGeo, baseMat);
94
+ base.castShadow = true;
95
+ base.receiveShadow = true;
96
+ base.position.copy(this.position);
97
+
98
+ this.mesh = base;
99
+ this.baseMesh = base;
100
+
101
+ this.kind = KINDS[this.type] || KINDS.basic;
102
+ this.kind.buildHead(this, scene);
103
+
104
+ const ringGeo = new THREE.RingGeometry(this.range - 0.05, this.range, 48);
105
+ const ringMat = new THREE.MeshBasicMaterial({
106
+ color:
107
+ this.type === "slow" ? 0xff69b4 : this.isSniper ? 0x00ffff : 0x3a97ff,
108
+ transparent: true,
109
+ opacity: this.isSniper ? 0.32 : 0.2,
110
+ side: THREE.DoubleSide,
111
+ depthWrite: false,
112
+ depthTest: true,
113
+ });
114
+ const ring = new THREE.Mesh(ringGeo, ringMat);
115
+ ring.rotation.x = -Math.PI / 2;
116
+ ring.position.y = 0.03;
117
+ base.add(ring);
118
+ ring.visible = true;
119
+
120
+ const outlineGeo = new THREE.TorusGeometry(1.05, 0.04, 8, 32);
121
+ const outlineMat = new THREE.MeshBasicMaterial({
122
+ color: 0xffff66,
123
+ transparent: true,
124
+ opacity: 0.85,
125
+ depthWrite: false,
126
+ });
127
+ const outline = new THREE.Mesh(outlineGeo, outlineMat);
128
+ outline.rotation.x = Math.PI / 2;
129
+ outline.position.y = 0.52;
130
+ outline.visible = false;
131
+ outline.name = "tower_hover_outline";
132
+ base.add(outline);
133
+
134
+ this.ring = ring;
135
+ this.hoverOutline = outline;
136
+ this.levelRing = null;
137
+
138
+ if (this.isElectric) {
139
+ this.applyVisualLevel();
140
+ }
141
+
142
+ scene.add(base);
143
+ }
144
+
145
+ get canUpgrade() {
146
+ return this.level < UPGRADE_MAX_LEVEL;
147
+ }
148
+
149
+ getSellValue() {
150
+ return Math.floor(this.totalSpent * SELL_REFUND_RATE);
151
+ }
152
+
153
+ upgrade() {
154
+ if (!this.canUpgrade) return false;
155
+
156
+ this.level += 1;
157
+ this.range *= UPGRADE_RANGE_SCALE;
158
+ this.rate *= UPGRADE_RATE_SCALE;
159
+ this.damage *= UPGRADE_DAMAGE_SCALE;
160
+
161
+ if (this.type === "slow") {
162
+ const idx = Math.max(
163
+ 0,
164
+ Math.min(this.slowMultByLevel.length - 1, this.level - 1)
165
+ );
166
+ const effect = this.projectileEffect || {};
167
+ this.projectileEffect = {
168
+ ...effect,
169
+ type: "slow",
170
+ mult: this.slowMultByLevel[idx],
171
+ duration: this.slowDuration,
172
+ };
173
+ }
174
+
175
+ if (this.isSniper) {
176
+ const minAimTime = (this.aimTime ?? 0) * 0.4;
177
+ this.aimTime = Math.max(minAimTime, (this.aimTime ?? 0) * 0.9);
178
+ this.pierceChance = Math.min(0.15, (this.pierceChance ?? 0) + 0.03);
179
+ }
180
+
181
+ const newGeo = new THREE.RingGeometry(this.range - 0.05, this.range, 48);
182
+ this.ring.geometry.dispose();
183
+ this.ring.geometry = newGeo;
184
+
185
+ this.totalSpent += this.nextUpgradeCost;
186
+ this.nextUpgradeCost = Math.round(
187
+ this.nextUpgradeCost * UPGRADE_COST_SCALE
188
+ );
189
+
190
+ this.applyVisualLevel();
191
+
192
+ return true;
193
+ }
194
+
195
+ applyVisualLevel() {
196
+ this.kind.applyVisualLevel(this);
197
+ }
198
+
199
+ tryFire(dt, enemies, projectiles, projectileSpeed) {
200
+ return this.kind.tryFire(this, dt, enemies, projectiles, projectileSpeed);
201
+ }
202
+
203
+ updateElectricArcs(now) {
204
+ if (this.kind.updateElectricArcs) {
205
+ this.kind.updateElectricArcs(this, now);
206
+ }
207
+ }
208
+
209
+ playShootSound() {
210
+ if (
211
+ typeof window !== "undefined" &&
212
+ window.UIManager &&
213
+ window.UIManager.playTowerSound
214
+ ) {
215
+ try {
216
+ window.UIManager.playTowerSound(this);
217
+ } catch {}
218
+ }
219
+ }
220
+
221
+ playAimingTone() {
222
+ if (
223
+ typeof window !== "undefined" &&
224
+ window.UIManager &&
225
+ window.UIManager.playAimingTone
226
+ ) {
227
+ try {
228
+ window.UIManager.playAimingTone(this);
229
+ } catch {}
230
+ }
231
+ }
232
+
233
+ stopAimingTone() {
234
+ if (
235
+ typeof window !== "undefined" &&
236
+ window.UIManager &&
237
+ window.UIManager.stopAimingTone
238
+ ) {
239
+ try {
240
+ window.UIManager.stopAimingTone(this);
241
+ } catch {}
242
+ }
243
+ }
244
+
245
+ playFireCrack() {
246
+ if (
247
+ typeof window !== "undefined" &&
248
+ window.UIManager &&
249
+ window.UIManager.playFireCrack
250
+ ) {
251
+ try {
252
+ window.UIManager.playFireCrack(this);
253
+ } catch {}
254
+ }
255
+ }
256
+
257
+ setSelected(selected) {
258
+ this.selected = !!selected;
259
+ if (this.hoverOutline) {
260
+ this.hoverOutline.visible = this.selected || !!this.hovered;
261
+ this.hoverOutline.material.opacity = this.selected ? 0.95 : 0.85;
262
+ }
263
+ }
264
+
265
+ setHovered(hovered) {
266
+ this.hovered = !!hovered;
267
+ if (this.hoverOutline) {
268
+ this.hoverOutline.visible = this.selected || this.hovered;
269
+ }
270
+ }
271
+
272
+ destroy() {
273
+ if (this.kind.onDestroy) {
274
+ this.kind.onDestroy(this);
275
+ }
276
+
277
+ if (this.levelRing) {
278
+ this.scene.remove(this.levelRing);
279
+ this.levelRing.geometry.dispose();
280
+ if (this.levelRing.material?.dispose) this.levelRing.material.dispose();
281
+ this.levelRing = null;
282
+ }
283
+ this.scene.remove(this.mesh);
284
+ }
285
+ }
src/entities/towers/common/targeting.js ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export function findNearestWithinRange(tower, enemies) {
2
+ let nearest = null;
3
+ let nearestDistSq = Infinity;
4
+
5
+ for (const e of enemies) {
6
+ const dSq = e.mesh.position.distanceToSquared(tower.position);
7
+ if (dSq <= tower.range * tower.range && dSq < nearestDistSq) {
8
+ nearest = e;
9
+ nearestDistSq = dSq;
10
+ }
11
+ }
12
+
13
+ return nearest;
14
+ }
15
+
16
+ export function findClosestToExitInRange(tower, enemies) {
17
+ const inRange = [];
18
+ const rangeSq = tower.range * tower.range;
19
+ for (const e of enemies) {
20
+ const dSq = e.mesh.position.distanceToSquared(tower.position);
21
+ if (dSq <= rangeSq) {
22
+ inRange.push(e);
23
+ }
24
+ }
25
+ if (inRange.length === 0) return null;
26
+
27
+ const towerPos = tower.position;
28
+ inRange.sort((a, b) => {
29
+ const segA = a.currentSeg ?? 0;
30
+ const segB = b.currentSeg ?? 0;
31
+ if (segA !== segB) return segB - segA;
32
+
33
+ const remA = a.target
34
+ ? a.target.distanceTo(a.position ?? a.mesh.position)
35
+ : Infinity;
36
+ const remB = b.target
37
+ ? b.target.distanceTo(b.position ?? b.mesh.position)
38
+ : Infinity;
39
+ if (remA !== remB) return remA - remB;
40
+
41
+ const da = (a.mesh?.position || a.position).distanceTo(towerPos);
42
+ const db = (b.mesh?.position || b.position).distanceTo(towerPos);
43
+ return da - db;
44
+ });
45
+
46
+ return inRange[0] || null;
47
+ }
48
+
49
+ export function findMultipleInRangeOrdered(tower, enemies, priority = "nearest") {
50
+ const rangeSq = tower.range * tower.range;
51
+ const inRange = [];
52
+ for (const e of enemies) {
53
+ const dSq = e.mesh.position.distanceToSquared(tower.position);
54
+ if (dSq <= rangeSq) inRange.push(e);
55
+ }
56
+ if (inRange.length === 0) return [];
57
+
58
+ if (priority === "closestToExit") {
59
+ const towerPos = tower.position;
60
+ inRange.sort((a, b) => {
61
+ const segA = a.currentSeg ?? 0;
62
+ const segB = b.currentSeg ?? 0;
63
+ if (segA !== segB) return segB - segA;
64
+ const remA = a.target
65
+ ? a.target.distanceTo(a.position ?? a.mesh.position)
66
+ : Infinity;
67
+ const remB = b.target
68
+ ? b.target.distanceTo(b.position ?? b.mesh.position)
69
+ : Infinity;
70
+ if (remA !== remB) return remA - remB;
71
+ const da = (a.mesh?.position || a.position).distanceTo(towerPos);
72
+ const db = (b.mesh?.position || b.position).distanceTo(towerPos);
73
+ return da - db;
74
+ });
75
+ } else {
76
+ const towerPos = tower.position;
77
+ inRange.sort((a, b) => {
78
+ const da = (a.mesh?.position || a.position).distanceTo(towerPos);
79
+ const db = (b.mesh?.position || b.position).distanceTo(towerPos);
80
+ return da - db;
81
+ });
82
+ }
83
+
84
+ return inRange;
85
+ }
src/entities/towers/kinds/basic.js ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as THREE from "three";
2
+ import { Projectile } from "../../Projectile.js";
3
+ import { findNearestWithinRange } from "../common/targeting.js";
4
+
5
+ const VISUAL_TOP_INCREMENT = 0.08;
6
+ const VISUAL_TOP_CAP = 0.4;
7
+
8
+ export default {
9
+ key: "basic",
10
+
11
+ buildHead(tower) {
12
+ const headGeo = new THREE.BoxGeometry(0.8, 0.4, 0.8);
13
+ const headMat = new THREE.MeshStandardMaterial({
14
+ color: 0x90caf9,
15
+ metalness: 0.18,
16
+ roughness: 0.45,
17
+ emissive: 0x000000,
18
+ emissiveIntensity: 0.6,
19
+ side: THREE.DoubleSide,
20
+ });
21
+
22
+ const head = new THREE.Mesh(headGeo, headMat);
23
+ head.castShadow = true;
24
+ head.position.set(0, 0.8, 0);
25
+ tower.baseMesh.add(head);
26
+
27
+ tower.headMesh = head;
28
+ tower.head = head;
29
+ tower.headTopY = tower.mesh.position.y + head.position.y + 0.4;
30
+ },
31
+
32
+ tryFire(tower, dt, enemies, projectiles, projectileSpeed) {
33
+ tower.fireCooldown -= dt;
34
+ if (tower.fireCooldown > 0) return;
35
+
36
+ const target = findNearestWithinRange(tower, enemies);
37
+ if (!target) return;
38
+
39
+ const dir = new THREE.Vector3().subVectors(
40
+ target.mesh.position,
41
+ tower.position
42
+ );
43
+ const yaw = Math.atan2(dir.x, dir.z);
44
+ tower.mesh.rotation.y = yaw;
45
+
46
+ tower.fireCooldown = 1 / tower.rate;
47
+
48
+ const spawnY =
49
+ typeof tower.headTopY === "number" ? tower.headTopY - 0.1 : 0.9;
50
+ const proj = new Projectile(
51
+ tower.position.clone().add(new THREE.Vector3(0, spawnY, 0)),
52
+ target,
53
+ projectileSpeed,
54
+ tower.scene,
55
+ tower.projectileEffect || null
56
+ );
57
+ proj.damage = tower.damage;
58
+ projectiles.push(proj);
59
+
60
+ tower.playShootSound();
61
+ },
62
+
63
+ applyVisualLevel(tower) {
64
+ const lvl = tower.level;
65
+ const head = tower.headMesh;
66
+ if (!head) return;
67
+
68
+ const baseMat = tower.baseMesh?.material;
69
+ const headMat = head.material;
70
+
71
+ if (tower.levelRing) {
72
+ tower.scene.remove(tower.levelRing);
73
+ tower.levelRing.geometry.dispose();
74
+ if (tower.levelRing.material?.dispose) tower.levelRing.material.dispose();
75
+ tower.levelRing = null;
76
+ }
77
+
78
+ const extraRaw = Math.max(0, (lvl - 1) * VISUAL_TOP_INCREMENT);
79
+ const visualExtra = Math.min(VISUAL_TOP_CAP, extraRaw);
80
+
81
+ if (lvl <= 1) {
82
+ if (baseMat) {
83
+ baseMat.color?.set?.(0x5c6bc0);
84
+ baseMat.emissive?.set?.(0x000000);
85
+ baseMat.emissiveIntensity = 0.0;
86
+ }
87
+
88
+ head.scale.set(1, 1, 1);
89
+ head.position.y = 0.65 + visualExtra;
90
+ tower.headTopY = (tower.mesh?.position.y ?? 0.25) + head.position.y + 0.4;
91
+
92
+ headMat.color?.set?.(0x90caf9);
93
+ headMat.emissive?.set?.(0x4a0a2a);
94
+ headMat.emissiveIntensity = 0.2;
95
+ } else {
96
+ if (baseMat) {
97
+ baseMat.color?.set?.(0x6f7bd6);
98
+ baseMat.emissive?.set?.(0x2a0a1a);
99
+ baseMat.emissiveIntensity = 0.08;
100
+ }
101
+
102
+ head.scale.set(1, 2, 1);
103
+ head.position.y = 0.65 + 0.4 + visualExtra;
104
+ tower.headTopY = (tower.mesh?.position.y ?? 0.25) + head.position.y + 0.8;
105
+
106
+ headMat.color?.set?.(0xa5d6ff);
107
+ headMat.emissive?.set?.(0x9a135a);
108
+ headMat.emissiveIntensity = 0.35;
109
+
110
+ const ringGeom = new THREE.TorusGeometry(0.45, 0.035, 8, 24);
111
+ const ringMat = new THREE.MeshStandardMaterial({
112
+ color: 0x3aa6ff,
113
+ emissive: 0xe01a6b,
114
+ emissiveIntensity: 0.55,
115
+ metalness: 0.3,
116
+ roughness: 0.45,
117
+ });
118
+ const ring = new THREE.Mesh(ringGeom, ringMat);
119
+ ring.castShadow = false;
120
+ ring.receiveShadow = false;
121
+
122
+ const topY = tower.headTopY ?? head.position.y + 0.8;
123
+ ring.position.set(
124
+ tower.mesh.position.x,
125
+ topY + 0.02,
126
+ tower.mesh.position.z
127
+ );
128
+ ring.rotation.x = Math.PI / 2;
129
+ ring.name = "tower_level_ring";
130
+
131
+ tower.levelRing = ring;
132
+ tower.scene.add(ring);
133
+ }
134
+ },
135
+ };
src/entities/towers/kinds/electric.js ADDED
@@ -0,0 +1,433 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as THREE from "three";
2
+ import { findMultipleInRangeOrdered } from "../common/targeting.js";
3
+
4
+ const ELECTRIC_BALL_LIFT_PER_LEVEL = 0.08;
5
+ const ELECTRIC_BALL_LIFT_CAP = 0.35;
6
+
7
+ export default {
8
+ key: "electric",
9
+
10
+ buildHead(tower) {
11
+ const headGroup = new THREE.Group();
12
+
13
+ const barHeight = 0.9;
14
+ const barGeo = new THREE.CylinderGeometry(0.08, 0.08, barHeight, 16);
15
+ const barMat = new THREE.MeshStandardMaterial({
16
+ color: 0x1b1f24,
17
+ metalness: 0.4,
18
+ roughness: 0.6,
19
+ });
20
+ const bar = new THREE.Mesh(barGeo, barMat);
21
+ bar.castShadow = true;
22
+ bar.receiveShadow = true;
23
+ bar.position.set(0, 0.5 + barHeight * 0.5, 0);
24
+
25
+ const headGeo = new THREE.SphereGeometry(0.45, 20, 16);
26
+ const headMat = new THREE.MeshStandardMaterial({
27
+ color: 0x9ad6ff,
28
+ metalness: 0.18,
29
+ roughness: 0.45,
30
+ emissive: 0x153a6b,
31
+ emissiveIntensity: 0.85,
32
+ side: THREE.DoubleSide,
33
+ });
34
+
35
+ const head = new THREE.Mesh(headGeo, headMat);
36
+ head.castShadow = true;
37
+ head.position.set(0, 0.5 + barHeight + 0.25, 0);
38
+ head.scale.set(1.0, 1.0, 1.0);
39
+
40
+ headGroup.add(bar);
41
+ headGroup.add(head);
42
+
43
+ const haloGeo = new THREE.TorusGeometry(0.38, 0.02, 8, 24);
44
+ const haloMat = new THREE.MeshStandardMaterial({
45
+ color: 0x80e1ff,
46
+ emissive: 0x206a99,
47
+ emissiveIntensity: 0.4,
48
+ metalness: 0.2,
49
+ roughness: 0.6,
50
+ });
51
+ const halo = new THREE.Mesh(haloGeo, haloMat);
52
+ halo.rotation.x = Math.PI / 2;
53
+ halo.position.set(0, head.position.y - 0.22, 0);
54
+ halo.castShadow = false;
55
+ halo.receiveShadow = false;
56
+ headGroup.add(halo);
57
+
58
+ tower.baseMesh.add(headGroup);
59
+
60
+ tower.headMesh = head;
61
+ tower.head = head;
62
+ tower.headTopY = tower.mesh.position.y + head.position.y + 0.45;
63
+
64
+ tower.trackedTargets = new Map();
65
+ tower.arcPool = [];
66
+ tower._visualAccumulator = 0;
67
+ tower.activeArcs = [];
68
+ },
69
+
70
+ tryFire(tower, dt, enemies) {
71
+ this.updateElectric(tower, dt, enemies);
72
+ this.updateElectricArcs(tower);
73
+ },
74
+
75
+ updateElectric(tower, dt, enemies) {
76
+ const current = this._refreshElectricTargets(tower, enemies);
77
+ if (!current.length) {
78
+ return;
79
+ }
80
+
81
+ const primary = current[0];
82
+ if (primary?.mesh?.position) {
83
+ const dir = new THREE.Vector3().subVectors(
84
+ primary.mesh.position,
85
+ tower.position
86
+ );
87
+ const yaw = Math.atan2(dir.x, dir.z);
88
+ tower.mesh.rotation.y = yaw;
89
+ }
90
+
91
+ const dps = tower.damagePerSecond ?? 1;
92
+ for (const enemy of current) {
93
+ if (!enemy || enemy.isDead?.()) continue;
94
+ enemy.takeDamage?.(dps * dt);
95
+ }
96
+
97
+ tower._visualAccumulator += dt;
98
+ const doRefresh = tower._visualAccumulator >= tower.visualRefreshInterval;
99
+ if (doRefresh) tower._visualAccumulator = 0;
100
+
101
+ const start = tower.position
102
+ .clone()
103
+ .add(new THREE.Vector3(0, tower.headTopY ?? 0.9, 0));
104
+ for (const enemy of current) {
105
+ const info = tower.trackedTargets.get(enemy);
106
+ const end = (enemy.mesh?.position || enemy.position)?.clone?.();
107
+ if (!end) continue;
108
+
109
+ if (!info.arc) {
110
+ info.arc = this._getArcInstance(tower, start, end);
111
+ if (info.arc?.lineOuter?.material)
112
+ info.arc.lineOuter.material.opacity = 0.0;
113
+ if (info.arc?.lineInner?.material)
114
+ info.arc.lineInner.material.opacity = 0.0;
115
+ info.fadeInTimer = tower.arcFadeDuration ?? 0.2;
116
+ }
117
+ if (doRefresh) this._updateArcGeometry(tower, info.arc, start, end, true);
118
+
119
+ if (typeof info.fadeInTimer === "number" && info.fadeInTimer > 0) {
120
+ info.fadeInTimer = Math.max(0, info.fadeInTimer - dt);
121
+ const denom = tower.arcFadeDuration || 0.2;
122
+ const t = denom > 0 ? 1 - info.fadeInTimer / denom : 1;
123
+ const outer = info.arc.lineOuter ? info.arc.lineOuter.material : null;
124
+ const inner = info.arc.lineInner ? info.arc.lineInner.material : null;
125
+ if (outer) {
126
+ outer.opacity = 0.5 * Math.min(1, t);
127
+ outer.needsUpdate = true;
128
+ }
129
+ if (inner) {
130
+ inner.opacity = 0.95 * Math.min(1, t);
131
+ inner.needsUpdate = true;
132
+ }
133
+ }
134
+
135
+ info.visible = true;
136
+ info.lastEnd = end;
137
+ }
138
+ },
139
+
140
+ _refreshElectricTargets(tower, enemies) {
141
+ const desired = findMultipleInRangeOrdered(
142
+ tower,
143
+ enemies,
144
+ tower.targetPriority || "closestToExit"
145
+ ).slice(0, tower.maxTargets || 4);
146
+ const prev = tower.trackedTargets || new Map();
147
+
148
+ const desiredSet = new Set(desired);
149
+ const currentSet = new Set(prev.keys());
150
+
151
+ for (const e of currentSet) {
152
+ if (!desiredSet.has(e)) {
153
+ const info = prev.get(e);
154
+ if (info?.arc) {
155
+ const now = performance.now();
156
+ const durMs = Math.max(1, (tower.arcFadeDuration || 0.2) * 1000);
157
+ tower.activeArcs ||= [];
158
+ tower.activeArcs.push({
159
+ lineOuter: info.arc.lineOuter,
160
+ lineInner: info.arc.lineInner,
161
+ createdAt: now,
162
+ duration: durMs,
163
+ });
164
+ }
165
+ prev.delete(e);
166
+ }
167
+ }
168
+
169
+ let hasNewTargets = false;
170
+ for (const e of desired) {
171
+ if (!prev.has(e)) {
172
+ prev.set(e, {
173
+ arc: null,
174
+ fadeInTimer: 0,
175
+ lastEnd: null,
176
+ visible: false,
177
+ });
178
+ hasNewTargets = true;
179
+ }
180
+ }
181
+
182
+ if (hasNewTargets && desired.length > 0) {
183
+ tower.playShootSound();
184
+ }
185
+
186
+ tower.trackedTargets = prev;
187
+ return desired;
188
+ },
189
+
190
+ _getArcInstance(tower, start, end) {
191
+ if (tower.arcPool && tower.arcPool.length > 0) {
192
+ const pooled = tower.arcPool.pop();
193
+ this._updateArcGeometry(tower, pooled, start, end, true);
194
+ if (pooled.lineOuter) pooled.lineOuter.visible = true;
195
+ if (pooled.lineInner) pooled.lineInner.visible = true;
196
+ return {
197
+ lineOuter: pooled.lineOuter,
198
+ lineInner: pooled.lineInner,
199
+ createdAt: performance.now(),
200
+ duration: (tower.arcFadeDuration || 0.2) * 1000,
201
+ };
202
+ }
203
+ return this.createElectricArc(tower, start, end);
204
+ },
205
+
206
+ createElectricArc(tower, start, end) {
207
+ const style = tower.arcStyle || {};
208
+ const segs = Math.max(2, style.segments ?? 10);
209
+ const jitter = style.jitter ?? 0.25;
210
+
211
+ const dir = new THREE.Vector3().subVectors(end, start);
212
+ const len = dir.length();
213
+ if (len < 1e-4) dir.set(0, 0, 1);
214
+ else dir.normalize();
215
+
216
+ const up = new THREE.Vector3(0, 1, 0);
217
+ let right = new THREE.Vector3().crossVectors(dir, up);
218
+ if (right.lengthSq() < 1e-6) {
219
+ right = new THREE.Vector3(1, 0, 0);
220
+ } else {
221
+ right.normalize();
222
+ }
223
+ const binorm = new THREE.Vector3().crossVectors(dir, right).normalize();
224
+
225
+ const points = [];
226
+ for (let i = 0; i <= segs; i++) {
227
+ const t = i / segs;
228
+ const base = new THREE.Vector3()
229
+ .copy(start)
230
+ .addScaledVector(dir, len * t);
231
+ const amp = jitter * (1 - Math.abs(0.5 - t) * 2);
232
+ const offR = (Math.random() * 2 - 1) * amp;
233
+ const offB = (Math.random() * 2 - 1) * amp;
234
+ base.addScaledVector(right, offR).addScaledVector(binorm, offB);
235
+ base.y += 0.01;
236
+ points.push(base);
237
+ }
238
+
239
+ const geometry = new THREE.BufferGeometry().setFromPoints(points);
240
+ const matOuter = new THREE.LineBasicMaterial({
241
+ color: style.color ?? 0x9ad6ff,
242
+ transparent: true,
243
+ opacity: 0.5,
244
+ linewidth: (style.thickness ?? 2) * 1.8,
245
+ depthWrite: false,
246
+ });
247
+ const matInner = new THREE.LineBasicMaterial({
248
+ color: style.coreColor ?? 0xe6fbff,
249
+ transparent: true,
250
+ opacity: 0.95,
251
+ linewidth: style.thickness ?? 2,
252
+ depthWrite: false,
253
+ });
254
+
255
+ const lineOuter = new THREE.Line(geometry, matOuter);
256
+ const lineInner = new THREE.Line(geometry.clone(), matInner);
257
+
258
+ tower.scene.add(lineOuter);
259
+ tower.scene.add(lineInner);
260
+
261
+ lineOuter.visible = true;
262
+ lineInner.visible = true;
263
+
264
+ return {
265
+ lineOuter,
266
+ lineInner,
267
+ createdAt: performance.now(),
268
+ duration: tower.arcDurationMs ?? 120,
269
+ };
270
+ },
271
+
272
+ _updateArcGeometry(tower, arc, start, end, rebuild = true) {
273
+ if (!arc?.lineOuter || !arc?.lineInner) return;
274
+
275
+ if (rebuild) {
276
+ const style = tower.arcStyle || {};
277
+ const segs = Math.max(2, style.segments ?? 10);
278
+ const jitter = style.jitter ?? 0.25;
279
+
280
+ const dir = new THREE.Vector3().subVectors(end, start);
281
+ const len = dir.length();
282
+ if (len < 1e-4) dir.set(0, 0, 1);
283
+ else dir.normalize();
284
+
285
+ const up = new THREE.Vector3(0, 1, 0);
286
+ let right = new THREE.Vector3().crossVectors(dir, up);
287
+ if (right.lengthSq() < 1e-6) right = new THREE.Vector3(1, 0, 0);
288
+ else right.normalize();
289
+ const binorm = new THREE.Vector3().crossVectors(dir, right).normalize();
290
+
291
+ const points = [];
292
+ for (let i = 0; i <= segs; i++) {
293
+ const t = i / segs;
294
+ const base = new THREE.Vector3()
295
+ .copy(start)
296
+ .addScaledVector(dir, len * t);
297
+ const amp = jitter * (1 - Math.abs(0.5 - t) * 2);
298
+ const offR = (Math.random() * 2 - 1) * amp;
299
+ const offB = (Math.random() * 2 - 1) * amp;
300
+ base.addScaledVector(right, offR).addScaledVector(binorm, offB);
301
+ base.y += 0.01;
302
+ points.push(base);
303
+ }
304
+
305
+ const newGeo = new THREE.BufferGeometry().setFromPoints(points);
306
+ const oldOuter = arc.lineOuter.geometry;
307
+ const oldInner = arc.lineInner.geometry;
308
+ arc.lineOuter.geometry = newGeo;
309
+ arc.lineInner.geometry = newGeo.clone();
310
+ oldOuter?.dispose?.();
311
+ oldInner?.dispose?.();
312
+ } else {
313
+ const positions = arc.lineOuter.geometry.attributes.position;
314
+ positions.setXYZ(0, start.x, start.y, start.z);
315
+ positions.setXYZ(positions.count - 1, end.x, end.y, end.z);
316
+ positions.needsUpdate = true;
317
+ const positions2 = arc.lineInner.geometry.attributes.position;
318
+ positions2.setXYZ(0, start.x, start.y, start.z);
319
+ positions2.setXYZ(positions2.count - 1, end.x, end.y, end.z);
320
+ positions2.needsUpdate = true;
321
+ }
322
+ },
323
+
324
+ updateElectricArcs(tower, nowMs = performance.now()) {
325
+ if (!tower.activeArcs || tower.activeArcs.length === 0) return;
326
+ const remain = [];
327
+ for (const arc of tower.activeArcs) {
328
+ const t = Math.max(
329
+ 0,
330
+ Math.min(1, (nowMs - arc.createdAt) / arc.duration)
331
+ );
332
+ const fade = 1.0 - t;
333
+ if (arc.lineOuter?.material) {
334
+ arc.lineOuter.material.opacity = 0.5 * fade;
335
+ arc.lineOuter.material.needsUpdate = true;
336
+ }
337
+ if (arc.lineInner?.material) {
338
+ arc.lineInner.material.opacity = 0.95 * fade;
339
+ arc.lineInner.material.needsUpdate = true;
340
+ }
341
+ if (t < 1) {
342
+ remain.push(arc);
343
+ } else {
344
+ if (arc.lineOuter || arc.lineInner) {
345
+ tower.arcPool ||= [];
346
+ if (arc.lineOuter) arc.lineOuter.visible = false;
347
+ if (arc.lineInner) arc.lineInner.visible = false;
348
+ tower.arcPool.push({
349
+ lineOuter: arc.lineOuter,
350
+ lineInner: arc.lineInner,
351
+ });
352
+ }
353
+ }
354
+ }
355
+ tower.activeArcs = remain;
356
+ },
357
+
358
+ applyVisualLevel(tower) {
359
+ const lvl = tower.level;
360
+ const head = tower.headMesh;
361
+ if (!head) return;
362
+
363
+ const liftRaw = Math.max(0, (lvl - 1) * ELECTRIC_BALL_LIFT_PER_LEVEL);
364
+ const lift = Math.min(ELECTRIC_BALL_LIFT_CAP, liftRaw);
365
+ head.position.y = head.position.y + lift;
366
+ tower.headTopY =
367
+ (tower.mesh?.position.y ?? 0.25) + head.position.y + 0.45;
368
+
369
+ if (lvl > 1) {
370
+ head.material.color?.set?.(0xa5d6ff);
371
+ head.material.emissive?.set?.(0x9a135a);
372
+ head.material.emissiveIntensity = 0.35;
373
+
374
+ const ringGeom = new THREE.TorusGeometry(0.45, 0.035, 8, 24);
375
+ const ringMat = new THREE.MeshStandardMaterial({
376
+ color: 0x3aa6ff,
377
+ emissive: 0xe01a6b,
378
+ emissiveIntensity: 0.55,
379
+ metalness: 0.3,
380
+ roughness: 0.45,
381
+ });
382
+ const ring = new THREE.Mesh(ringGeom, ringMat);
383
+ ring.castShadow = false;
384
+ ring.receiveShadow = false;
385
+
386
+ const topY = tower.headTopY ?? head.position.y + 0.8;
387
+ ring.position.set(
388
+ tower.mesh.position.x,
389
+ topY + 0.02,
390
+ tower.mesh.position.z
391
+ );
392
+ ring.rotation.x = Math.PI / 2;
393
+ ring.name = "tower_level_ring";
394
+
395
+ tower.levelRing = ring;
396
+ tower.scene.add(ring);
397
+ }
398
+ },
399
+
400
+ onDestroy(tower) {
401
+ const disposeArc = (arcObj) => {
402
+ if (!arcObj) return;
403
+ const { lineOuter, lineInner } = arcObj;
404
+ if (lineOuter) {
405
+ tower.scene.remove(lineOuter);
406
+ lineOuter.geometry?.dispose?.();
407
+ lineOuter.material?.dispose?.();
408
+ }
409
+ if (lineInner) {
410
+ tower.scene.remove(lineInner);
411
+ lineInner.geometry?.dispose?.();
412
+ lineInner.material?.dispose?.();
413
+ }
414
+ };
415
+
416
+ if (tower.activeArcs && tower.activeArcs.length) {
417
+ for (const arc of tower.activeArcs) disposeArc(arc);
418
+ tower.activeArcs = [];
419
+ }
420
+
421
+ if (tower.trackedTargets && tower.trackedTargets.size) {
422
+ for (const info of tower.trackedTargets.values()) {
423
+ if (info.arc) disposeArc(info.arc);
424
+ }
425
+ tower.trackedTargets.clear();
426
+ }
427
+
428
+ if (tower.arcPool && tower.arcPool.length) {
429
+ for (const pooled of tower.arcPool) disposeArc(pooled);
430
+ tower.arcPool.length = 0;
431
+ }
432
+ },
433
+ };
src/entities/towers/kinds/index.js ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import basic from './basic.js';
2
+ import slow from './slow.js';
3
+ import sniper from './sniper.js';
4
+ import electric from './electric.js';
5
+
6
+ export const KINDS = {
7
+ basic,
8
+ slow,
9
+ sniper,
10
+ electric
11
+ };
src/entities/towers/kinds/slow.js ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as THREE from "three";
2
+ import { Projectile } from "../../Projectile.js";
3
+ import { findNearestWithinRange } from "../common/targeting.js";
4
+
5
+ const VISUAL_TOP_INCREMENT = 0.08;
6
+ const VISUAL_TOP_CAP = 0.4;
7
+
8
+ export default {
9
+ key: "slow",
10
+
11
+ buildHead(tower) {
12
+ const headGeo = new THREE.SphereGeometry(
13
+ 0.55,
14
+ 24,
15
+ 16,
16
+ 0,
17
+ Math.PI * 2,
18
+ Math.PI / 2,
19
+ Math.PI / 2
20
+ );
21
+ const headMat = new THREE.MeshStandardMaterial({
22
+ color: 0xffb6c1,
23
+ metalness: 0.15,
24
+ roughness: 0.35,
25
+ emissive: 0x4a0a2a,
26
+ emissiveIntensity: 0.4,
27
+ side: THREE.DoubleSide,
28
+ });
29
+
30
+ const head = new THREE.Mesh(headGeo, headMat);
31
+ head.castShadow = true;
32
+ head.position.set(0, 0.8, 0);
33
+ tower.baseMesh.add(head);
34
+
35
+ tower.headMesh = head;
36
+ tower.head = head;
37
+ tower.headTopY = tower.mesh.position.y + head.position.y + 0.4;
38
+ },
39
+
40
+ tryFire(tower, dt, enemies, projectiles, projectileSpeed) {
41
+ tower.fireCooldown -= dt;
42
+ if (tower.fireCooldown > 0) return;
43
+
44
+ const target = findNearestWithinRange(tower, enemies);
45
+ if (!target) return;
46
+
47
+ const dir = new THREE.Vector3().subVectors(
48
+ target.mesh.position,
49
+ tower.position
50
+ );
51
+ const yaw = Math.atan2(dir.x, dir.z);
52
+ tower.mesh.rotation.y = yaw;
53
+
54
+ tower.fireCooldown = 1 / tower.rate;
55
+
56
+ const spawnY =
57
+ typeof tower.headTopY === "number" ? tower.headTopY - 0.1 : 0.9;
58
+ const proj = new Projectile(
59
+ tower.position.clone().add(new THREE.Vector3(0, spawnY, 0)),
60
+ target,
61
+ projectileSpeed,
62
+ tower.scene,
63
+ tower.projectileEffect || null
64
+ );
65
+ proj.damage = tower.damage;
66
+ projectiles.push(proj);
67
+
68
+ tower.playShootSound();
69
+ },
70
+
71
+ applyVisualLevel(tower) {
72
+ const lvl = tower.level;
73
+ const head = tower.headMesh;
74
+ if (!head) return;
75
+
76
+ const baseMat = tower.baseMesh?.material;
77
+ const headMat = head.material;
78
+
79
+ if (tower.levelRing) {
80
+ tower.scene.remove(tower.levelRing);
81
+ tower.levelRing.geometry.dispose();
82
+ if (tower.levelRing.material?.dispose) tower.levelRing.material.dispose();
83
+ tower.levelRing = null;
84
+ }
85
+
86
+ const extraRaw = Math.max(0, (lvl - 1) * VISUAL_TOP_INCREMENT);
87
+ const visualExtra = Math.min(VISUAL_TOP_CAP, extraRaw);
88
+
89
+ if (lvl <= 1) {
90
+ if (baseMat) {
91
+ baseMat.color?.set?.(0xff69b4);
92
+ baseMat.emissive?.set?.(0x000000);
93
+ baseMat.emissiveIntensity = 0.0;
94
+ }
95
+
96
+ head.scale.set(1, 1, 1);
97
+ head.position.y = 0.8 + visualExtra;
98
+ tower.headTopY =
99
+ (tower.mesh?.position.y ?? 0.25) + head.position.y + 0.55;
100
+
101
+ headMat.color?.set?.(0xffb6c1);
102
+ headMat.emissive?.set?.(0x4a0a2a);
103
+ headMat.emissiveIntensity = 0.2;
104
+ } else {
105
+ if (baseMat) {
106
+ baseMat.color?.set?.(0xff5ea8);
107
+ baseMat.emissive?.set?.(0x2a0a1a);
108
+ baseMat.emissiveIntensity = 0.08;
109
+ }
110
+
111
+ head.scale.set(1.1, 1.15, 1.1);
112
+ head.position.y = 0.9 + visualExtra;
113
+ tower.headTopY = (tower.mesh?.position.y ?? 0.25) + head.position.y + 0.6;
114
+
115
+ headMat.color?.set?.(0xffc6d9);
116
+ headMat.emissive?.set?.(0x9a135a);
117
+ headMat.emissiveIntensity = 0.35;
118
+
119
+ const ringGeom = new THREE.TorusGeometry(0.45, 0.035, 8, 24);
120
+ const ringMat = new THREE.MeshStandardMaterial({
121
+ color: 0xff8fc2,
122
+ emissive: 0xe01a6b,
123
+ emissiveIntensity: 0.55,
124
+ metalness: 0.3,
125
+ roughness: 0.45,
126
+ });
127
+ const ring = new THREE.Mesh(ringGeom, ringMat);
128
+ ring.castShadow = false;
129
+ ring.receiveShadow = false;
130
+
131
+ const topY = tower.headTopY ?? head.position.y + 0.8;
132
+ ring.position.set(
133
+ tower.mesh.position.x,
134
+ topY + 0.02,
135
+ tower.mesh.position.z
136
+ );
137
+ ring.rotation.x = Math.PI / 2;
138
+ ring.name = "tower_level_ring";
139
+
140
+ tower.levelRing = ring;
141
+ tower.scene.add(ring);
142
+ }
143
+ },
144
+ };
src/entities/towers/kinds/sniper.js ADDED
@@ -0,0 +1,246 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as THREE from "three";
2
+ import { Projectile } from "../../Projectile.js";
3
+ import { findClosestToExitInRange } from "../common/targeting.js";
4
+
5
+ const VISUAL_TOP_INCREMENT = 0.08;
6
+ const VISUAL_TOP_CAP = 0.4;
7
+
8
+ export default {
9
+ key: "sniper",
10
+
11
+ buildHead(tower) {
12
+ const headGeo = new THREE.ConeGeometry(0.7, 0.9, 3);
13
+ const headMat = new THREE.MeshStandardMaterial({
14
+ color: 0xb0bec5,
15
+ metalness: 0.35,
16
+ roughness: 0.4,
17
+ emissive: 0x330000,
18
+ emissiveIntensity: 0.4,
19
+ side: THREE.DoubleSide,
20
+ });
21
+
22
+ const head = new THREE.Mesh(headGeo, headMat);
23
+ head.castShadow = true;
24
+ head.position.set(0, 0.95, 0);
25
+ head.rotation.x = 0;
26
+ tower.baseMesh.add(head);
27
+
28
+ tower.headMesh = head;
29
+ tower.head = head;
30
+ const headTopOffset = 0.55;
31
+ tower.headTopY = tower.mesh.position.y + head.position.y + headTopOffset;
32
+ },
33
+
34
+ tryFire(tower, dt, enemies, projectiles, projectileSpeed) {
35
+ tower.fireCooldown -= dt;
36
+
37
+ if (tower.aiming) {
38
+ const t = tower.aimedTarget;
39
+ const targetPos = t?.mesh?.position || t?.position;
40
+ const alive = t && !t.isDead();
41
+ const within =
42
+ alive &&
43
+ targetPos?.distanceToSquared(tower.position) <=
44
+ tower.cancelThreshold * tower.cancelThreshold;
45
+
46
+ if (!alive || !within) {
47
+ this.removeLaser(tower);
48
+ tower.stopAimingTone();
49
+ tower.aiming = false;
50
+ tower.aimedTarget = null;
51
+ } else {
52
+ const dir = new THREE.Vector3().subVectors(targetPos, tower.position);
53
+ const yaw = Math.atan2(dir.x, dir.z);
54
+ tower.mesh.rotation.y = yaw;
55
+
56
+ const start = tower.position
57
+ .clone()
58
+ .add(new THREE.Vector3(0, tower.headTopY, 0));
59
+ const end = targetPos.clone();
60
+ if (tower.laserLine) this.updateLaser(tower.laserLine, start, end);
61
+
62
+ tower.aimingTimer -= dt;
63
+ if (tower.aimingTimer <= 0) {
64
+ const spawnY =
65
+ typeof tower.headTopY === "number" ? tower.headTopY - 0.1 : 0.9;
66
+ const proj = new Projectile(
67
+ tower.position.clone().add(new THREE.Vector3(0, spawnY, 0)),
68
+ t,
69
+ tower.sniperProjectileSpeed ?? projectileSpeed,
70
+ tower.scene,
71
+ null
72
+ );
73
+ proj.damage = tower.damage;
74
+ projectiles.push(proj);
75
+
76
+ this.removeLaser(tower);
77
+ tower.stopAimingTone();
78
+ tower.playFireCrack();
79
+
80
+ tower.fireCooldown = 1 / tower.rate;
81
+
82
+ tower.aiming = false;
83
+ tower.aimedTarget = null;
84
+ }
85
+ }
86
+ return;
87
+ }
88
+
89
+ if (tower.fireCooldown > 0) return;
90
+
91
+ const target = findClosestToExitInRange(tower, enemies);
92
+ if (!target) return;
93
+
94
+ const dir = new THREE.Vector3().subVectors(
95
+ target.mesh.position,
96
+ tower.position
97
+ );
98
+ const yaw = Math.atan2(dir.x, dir.z);
99
+ tower.mesh.rotation.y = yaw;
100
+
101
+ tower.aimedTarget = target;
102
+ tower.aiming = true;
103
+ tower.aimingTimer = Math.max(0.01, tower.aimTime || 0.01);
104
+
105
+ const start = tower.position
106
+ .clone()
107
+ .add(new THREE.Vector3(0, tower.headTopY, 0));
108
+ const end = target.mesh.position.clone();
109
+ tower.laserLine = this.createLaser(tower, start, end);
110
+ tower.playAimingTone();
111
+ },
112
+
113
+ createLaser(tower, start, end) {
114
+ const points = [start.clone(), end.clone()];
115
+ const geometry = new THREE.BufferGeometry().setFromPoints(points);
116
+ const material = new THREE.LineBasicMaterial({
117
+ color: 0xff3b30,
118
+ transparent: true,
119
+ opacity: 0.9,
120
+ linewidth: 2,
121
+ });
122
+ const line = new THREE.Line(geometry, material);
123
+ line.position.y += 0.01;
124
+ tower.scene.add(line);
125
+ return line;
126
+ },
127
+
128
+ updateLaser(line, start, end) {
129
+ const positions = line.geometry.attributes.position;
130
+ positions.setXYZ(0, start.x, start.y, start.z);
131
+ positions.setXYZ(1, end.x, end.y, end.z);
132
+ positions.needsUpdate = true;
133
+ },
134
+
135
+ removeLaser(tower) {
136
+ if (tower.laserLine) {
137
+ tower.scene.remove(tower.laserLine);
138
+ if (tower.laserLine.geometry) tower.laserLine.geometry.dispose();
139
+ if (tower.laserLine.material?.dispose) tower.laserLine.material.dispose();
140
+ tower.laserLine = null;
141
+ }
142
+ },
143
+
144
+ applyVisualLevel(tower) {
145
+ const lvl = tower.level;
146
+ const head = tower.headMesh;
147
+ if (!head) return;
148
+
149
+ const baseMat = tower.baseMesh?.material;
150
+ const headMat = head.material;
151
+
152
+ // Remove previous ring if any
153
+ if (tower.levelRing) {
154
+ tower.scene.remove(tower.levelRing);
155
+ tower.levelRing.geometry.dispose();
156
+ if (tower.levelRing.material?.dispose) tower.levelRing.material.dispose();
157
+ tower.levelRing = null;
158
+ }
159
+
160
+ // Compute visual-only extra height based on level (starts at level 2)
161
+ const extraRaw = Math.max(0, (lvl - 1) * VISUAL_TOP_INCREMENT);
162
+ const visualExtra = Math.min(VISUAL_TOP_CAP, extraRaw);
163
+
164
+ if (lvl <= 1) {
165
+ // Default look for sniper
166
+ if (baseMat) {
167
+ baseMat.color?.set?.(0x6d6f73);
168
+ baseMat.emissive?.set?.(0x000000);
169
+ baseMat.emissiveIntensity = 0.0;
170
+ baseMat.metalness = 0.5;
171
+ baseMat.roughness = 0.35;
172
+ }
173
+
174
+ // Sniper cone head baseline
175
+ head.scale.set(1, 1, 1);
176
+ head.position.y = 0.95 + visualExtra;
177
+ tower.headTopY = (tower.mesh?.position.y ?? 0.25) + head.position.y + 0.55;
178
+
179
+ headMat.color?.set?.(0xb0bec5);
180
+ headMat.emissive?.set?.(0x330000);
181
+ headMat.emissiveIntensity = 0.4;
182
+ headMat.metalness = 0.35;
183
+ headMat.roughness = 0.4;
184
+ } else {
185
+ // Level 2+ look for sniper
186
+ if (baseMat) {
187
+ baseMat.color?.set?.(0x5d5f63);
188
+ baseMat.emissive?.set?.(0x2a0a1a);
189
+ baseMat.emissiveIntensity = 0.08;
190
+ baseMat.metalness = 0.55;
191
+ baseMat.roughness = 0.3;
192
+ }
193
+
194
+ // Slightly larger cone; raise by visualExtra
195
+ head.scale.set(1.1, 1.2, 1.1);
196
+ head.position.y = 0.95 + visualExtra;
197
+ tower.headTopY = (tower.mesh?.position.y ?? 0.25) + head.position.y + 0.6;
198
+
199
+ headMat.color?.set?.(0x90a4ae);
200
+ headMat.emissive?.set?.(0x550000);
201
+ headMat.emissiveIntensity = 0.5;
202
+ headMat.metalness = 0.4;
203
+ headMat.roughness = 0.35;
204
+
205
+ // Optional thin ring on top (same as basic tower)
206
+ const ringGeom = new THREE.TorusGeometry(0.45, 0.035, 8, 24);
207
+ const ringMat = new THREE.MeshStandardMaterial({
208
+ color: 0x00ffff, // Cyan for sniper
209
+ emissive: 0xe01a6b,
210
+ emissiveIntensity: 0.55,
211
+ metalness: 0.3,
212
+ roughness: 0.45,
213
+ });
214
+ const ring = new THREE.Mesh(ringGeom, ringMat);
215
+ ring.castShadow = false;
216
+ ring.receiveShadow = false;
217
+
218
+ const topY = tower.headTopY ?? head.position.y + 0.8;
219
+ ring.position.set(
220
+ tower.mesh.position.x,
221
+ topY + 0.02,
222
+ tower.mesh.position.z
223
+ );
224
+ ring.rotation.x = Math.PI / 2;
225
+ ring.name = "tower_level_ring";
226
+
227
+ tower.levelRing = ring;
228
+ tower.scene.add(ring);
229
+ }
230
+
231
+ // Update headTopY after position changes
232
+ const headTopOffset = 0.55;
233
+ tower.headTopY = tower.mesh.position.y + head.position.y + headTopOffset;
234
+
235
+ // Sniper-specific upgrades
236
+ if (lvl > 1) {
237
+ const minAimTime = (tower.aimTime ?? 0) * 0.4;
238
+ tower.aimTime = Math.max(minAimTime, (tower.aimTime ?? 0) * 0.9);
239
+ tower.pierceChance = Math.min(0.15, (tower.pierceChance ?? 0) + 0.03);
240
+ }
241
+ },
242
+
243
+ onDestroy(tower) {
244
+ this.removeLaser(tower);
245
+ },
246
+ };
src/main.js CHANGED
@@ -26,6 +26,9 @@ const gameState = new GameState();
26
  const uiManager = new UIManager();
27
  const effectSystem = new EffectSystem(sceneSetup.scene);
28
 
 
 
 
29
  // Build the path
30
  pathBuilder.buildPath();
31
 
@@ -238,6 +241,11 @@ function setSelectedTower(tower) {
238
  function onClick(e) {
239
  if (!gameState.isGameActive()) return;
240
 
 
 
 
 
 
241
  // If a drag/rotate/pan occurred, suppress the click action entirely
242
  if (didDrag) {
243
  didDrag = false; // reset for next interaction
 
26
  const uiManager = new UIManager();
27
  const effectSystem = new EffectSystem(sceneSetup.scene);
28
 
29
+ // Make UIManager globally accessible for tower sound system
30
+ window.UIManager = uiManager;
31
+
32
  // Build the path
33
  pathBuilder.buildPath();
34
 
 
241
  function onClick(e) {
242
  if (!gameState.isGameActive()) return;
243
 
244
+ // Ignore clicks on UI elements
245
+ if (e.target.tagName !== 'CANVAS') {
246
+ return;
247
+ }
248
+
249
  // If a drag/rotate/pan occurred, suppress the click action entirely
250
  if (didDrag) {
251
  didDrag = false; // reset for next interaction
src/ui/UIManager.js CHANGED
@@ -1,3 +1,5 @@
 
 
1
  export class UIManager {
2
  constructor() {
3
  // HUD elements
@@ -23,6 +25,13 @@ export class UIManager {
23
  this.palette.className = "palette hidden";
24
  document.body.appendChild(this.palette);
25
 
 
 
 
 
 
 
 
26
  this._paletteClickHandler = null;
27
  this._outsideHandler = (ev) => {
28
  if (this.palette.style.display === "none") return;
@@ -204,11 +213,17 @@ export class UIManager {
204
  }
205
 
206
  onUpgradeClick(callback) {
207
- this.upgradeBtn.addEventListener("click", callback);
 
 
 
208
  }
209
 
210
  onSellClick(callback) {
211
- this.sellBtn.addEventListener("click", callback);
 
 
 
212
  }
213
 
214
  // Palette API
@@ -385,17 +400,44 @@ export class UIManager {
385
 
386
  this.palette.appendChild(list);
387
 
388
- // Position palette; nudge to keep on-screen
389
- const pad = 8;
390
- const rectW = 200;
391
- this.palette.style.left =
392
- Math.min(window.innerWidth - rectW - pad, Math.max(pad, screenX + 10)) +
393
- "px";
394
- this.palette.style.top =
395
- Math.min(window.innerHeight - 160 - pad, Math.max(pad, screenY + 10)) +
396
- "px";
397
- this.palette.style.width = rectW + "px";
398
  this.palette.classList.remove("hidden");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
399
 
400
  // After rendering, ensure initial affordability reflects current money if subscribed
401
  if (this._gameStateForSubscriptions) {
@@ -407,6 +449,150 @@ export class UIManager {
407
  this.palette.classList.add("hidden");
408
  }
409
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
410
  /**
411
  * Optional teardown to prevent leaks: unsubscribe from GameState events.
412
  */
 
1
+ import { AUDIO_CONFIG } from "../config/gameConfig.js";
2
+
3
  export class UIManager {
4
  constructor() {
5
  // HUD elements
 
25
  this.palette.className = "palette hidden";
26
  document.body.appendChild(this.palette);
27
 
28
+ // Audio system
29
+ this.audioCache = {};
30
+ this.backgroundMusic = null;
31
+ this.musicVolume = AUDIO_CONFIG.musicVolume;
32
+ this.effectsVolume = AUDIO_CONFIG.effectsVolume;
33
+ this.initAudio();
34
+
35
  this._paletteClickHandler = null;
36
  this._outsideHandler = (ev) => {
37
  if (this.palette.style.display === "none") return;
 
213
  }
214
 
215
  onUpgradeClick(callback) {
216
+ this.upgradeBtn.addEventListener("click", (e) => {
217
+ e.stopPropagation();
218
+ callback();
219
+ });
220
  }
221
 
222
  onSellClick(callback) {
223
+ this.sellBtn.addEventListener("click", (e) => {
224
+ e.stopPropagation();
225
+ callback();
226
+ });
227
  }
228
 
229
  // Palette API
 
400
 
401
  this.palette.appendChild(list);
402
 
403
+ // Position palette initially to measure its size
404
+ this.palette.style.width = "200px";
 
 
 
 
 
 
 
 
405
  this.palette.classList.remove("hidden");
406
+
407
+ // Get actual dimensions after rendering
408
+ const rect = this.palette.getBoundingClientRect();
409
+ const menuWidth = rect.width;
410
+ const menuHeight = rect.height;
411
+
412
+ // Calculate position with proper bounds checking
413
+ const pad = 8;
414
+ let left = screenX + 10;
415
+ let top = screenY + 10;
416
+
417
+ // Ensure menu stays within viewport horizontally
418
+ if (left + menuWidth + pad > window.innerWidth) {
419
+ left = window.innerWidth - menuWidth - pad;
420
+ }
421
+ if (left < pad) {
422
+ left = pad;
423
+ }
424
+
425
+ // Ensure menu stays within viewport vertically
426
+ if (top + menuHeight + pad > window.innerHeight) {
427
+ // Try placing above the click point instead
428
+ top = screenY - menuHeight - 10;
429
+ if (top < pad) {
430
+ // If still doesn't fit, just cap at bottom of screen
431
+ top = window.innerHeight - menuHeight - pad;
432
+ }
433
+ }
434
+ if (top < pad) {
435
+ top = pad;
436
+ }
437
+
438
+ // Apply calculated position
439
+ this.palette.style.left = left + "px";
440
+ this.palette.style.top = top + "px";
441
 
442
  // After rendering, ensure initial affordability reflects current money if subscribed
443
  if (this._gameStateForSubscriptions) {
 
449
  this.palette.classList.add("hidden");
450
  }
451
 
452
+ // Audio initialization
453
+ initAudio() {
454
+ // Load sound effects
455
+ const soundFiles = {
456
+ basic: "./src/assets/basic.mp3",
457
+ slow: "./src/assets/slow.mp3",
458
+ sniper: "./src/assets/sniper.mp3",
459
+ electric: "./src/assets/electric.mp3"
460
+ };
461
+
462
+ for (const [type, path] of Object.entries(soundFiles)) {
463
+ try {
464
+ const audio = new Audio(path);
465
+ audio.volume = this.effectsVolume;
466
+ audio.preload = "auto";
467
+ this.audioCache[type] = audio;
468
+ } catch (e) {
469
+ console.warn(`Failed to load sound for ${type}:`, e);
470
+ }
471
+ }
472
+
473
+ // Load and start background music
474
+ if (AUDIO_CONFIG.musicEnabled) {
475
+ try {
476
+ console.log("Loading background music...");
477
+ this.backgroundMusic = new Audio("./src/assets/music.mp3");
478
+ this.backgroundMusic.volume = this.musicVolume;
479
+ this.backgroundMusic.loop = true;
480
+ this.backgroundMusic.preload = "auto";
481
+
482
+ // Add multiple event listeners for debugging
483
+ this.backgroundMusic.addEventListener('loadstart', () => {
484
+ console.log("Music load started");
485
+ });
486
+
487
+ this.backgroundMusic.addEventListener('canplay', () => {
488
+ console.log("Music can play");
489
+ });
490
+
491
+ this.backgroundMusic.addEventListener('error', (e) => {
492
+ console.error("Music load error:", e);
493
+ });
494
+
495
+ // Start playing music when it's loaded
496
+ this.backgroundMusic.addEventListener('canplaythrough', () => {
497
+ console.log("Music loaded, attempting to play...");
498
+ this.backgroundMusic.play().then(() => {
499
+ console.log("Music playing successfully!");
500
+ }).catch(e => {
501
+ console.log("Music autoplay blocked, will play on user interaction", e);
502
+ // Fallback: play on first user interaction
503
+ const playOnInteraction = () => {
504
+ console.log("Attempting to play music on user interaction...");
505
+ this.backgroundMusic.play().then(() => {
506
+ console.log("Music started after user interaction!");
507
+ }).catch((err) => {
508
+ console.error("Failed to play music:", err);
509
+ });
510
+ document.removeEventListener('click', playOnInteraction);
511
+ document.removeEventListener('keydown', playOnInteraction);
512
+ };
513
+ document.addEventListener('click', playOnInteraction);
514
+ document.addEventListener('keydown', playOnInteraction);
515
+ });
516
+ }, { once: true });
517
+
518
+ // Force load
519
+ this.backgroundMusic.load();
520
+ } catch (e) {
521
+ console.warn("Failed to load background music:", e);
522
+ }
523
+ } else {
524
+ console.log("Music is disabled in config");
525
+ }
526
+ }
527
+
528
+ // Play shooting sound for a tower
529
+ playTowerSound(tower) {
530
+ if (!tower || !tower.type || !AUDIO_CONFIG.effectsEnabled) return;
531
+
532
+ const audio = this.audioCache[tower.type];
533
+ if (audio) {
534
+ try {
535
+ // Clone and play to allow overlapping sounds
536
+ const audioClone = audio.cloneNode();
537
+ audioClone.volume = this.effectsVolume;
538
+ audioClone.play().catch(() => {});
539
+ } catch (e) {
540
+ // Fallback to direct play if cloning fails
541
+ audio.currentTime = 0;
542
+ audio.volume = this.effectsVolume;
543
+ audio.play().catch(() => {});
544
+ }
545
+ }
546
+ }
547
+
548
+ // Sniper aiming tone (optional, can be extended)
549
+ playAimingTone(tower) {
550
+ // Could play a subtle aiming sound if desired
551
+ }
552
+
553
+ // Stop aiming tone
554
+ stopAimingTone(tower) {
555
+ // Stop any aiming sound if implemented
556
+ }
557
+
558
+ // Sniper fire crack sound
559
+ playFireCrack(tower) {
560
+ // Use the sniper sound for fire crack
561
+ if (tower && tower.type === "sniper") {
562
+ this.playTowerSound(tower);
563
+ }
564
+ }
565
+
566
+ // Volume control methods
567
+ setMusicVolume(volume) {
568
+ this.musicVolume = Math.max(0, Math.min(1, volume));
569
+ if (this.backgroundMusic) {
570
+ this.backgroundMusic.volume = this.musicVolume;
571
+ }
572
+ }
573
+
574
+ setEffectsVolume(volume) {
575
+ this.effectsVolume = Math.max(0, Math.min(1, volume));
576
+ // Update all cached sound effects
577
+ for (const audio of Object.values(this.audioCache)) {
578
+ if (audio) audio.volume = this.effectsVolume;
579
+ }
580
+ }
581
+
582
+ toggleMusic() {
583
+ if (this.backgroundMusic) {
584
+ if (this.backgroundMusic.paused) {
585
+ this.backgroundMusic.play().catch(() => {});
586
+ } else {
587
+ this.backgroundMusic.pause();
588
+ }
589
+ }
590
+ }
591
+
592
+ toggleEffects() {
593
+ AUDIO_CONFIG.effectsEnabled = !AUDIO_CONFIG.effectsEnabled;
594
+ }
595
+
596
  /**
597
  * Optional teardown to prevent leaks: unsubscribe from GameState events.
598
  */