victor HF Staff commited on
Commit
b29710c
·
0 Parent(s):

Initial commit

Browse files
.DS_Store ADDED
Binary file (6.15 kB). View file
 
index.html ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Three.js Tower Defense</title>
7
+ <link rel="stylesheet" href="styles/theme.css">
8
+ <link rel="stylesheet" href="styles/ui.css">
9
+ <style>
10
+ body { margin: 0; overflow: hidden; }
11
+ canvas { display: block; }
12
+ </style>
13
+ </head>
14
+ <body>
15
+
16
+ <div class="hud">
17
+ <div class="panel panel--compact">
18
+ <div class="chips">
19
+ <span class="chip"><span class="chip__label">Money</span><b id="money">200</b></span>
20
+ <span class="chip"><span class="chip__label">Lives</span><b id="lives">10</b></span>
21
+ <span class="chip"><span class="chip__label">Wave</span><b id="wave">0</b>/<b id="wavesTotal">∞</b></span>
22
+ </div>
23
+ <div id="messages" class="message-bar hidden"></div>
24
+ <button id="restart" class="btn btn--primary hidden">Restart</button>
25
+ </div>
26
+
27
+ <div id="upgradePanel" class="upgrade-panel hidden">
28
+ <div class="panel-title">Selected Tower</div>
29
+ <div class="stat-grid">
30
+ <div class="stat-label">Level</div><div class="stat-value" id="t_level">1</div>
31
+ <div class="stat-label">Range</div><div class="stat-value" id="t_range">0</div>
32
+ <div class="stat-label">Fire Rate</div><div class="stat-value" id="t_rate">0</div>
33
+ <div class="stat-label">Damage</div><div class="stat-value" id="t_damage">0</div>
34
+ <div class="stat-label">Next Upgrade</div><div class="stat-value" id="t_nextCost">-</div>
35
+ </div>
36
+ <div class="u-flex u-gap-3">
37
+ <button id="upgradeBtn" class="btn btn--primary">Upgrade</button>
38
+ <button id="sellBtn" class="btn">Sell</button>
39
+ </div>
40
+ </div>
41
+ </div>
42
+
43
+ <script type="importmap">
44
+ {
45
+ "imports": {
46
+ "three": "https://unpkg.com/[email protected]/build/three.module.js",
47
+ "three/addons/": "https://unpkg.com/[email protected]/examples/jsm/"
48
+ }
49
+ }
50
+ </script>
51
+ <script type="module" src="src/main.js"></script>
52
+ </body>
53
+ </html>
src/config/gameConfig.js ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as THREE from "three";
2
+
3
+ // Game settings
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 = {
10
+ basic: {
11
+ key: "basic",
12
+ name: "Basic Tower",
13
+ type: "basic",
14
+ cost: 50,
15
+ range: 9,
16
+ fireRate: 1.0, // shots per second
17
+ damage: 6,
18
+ },
19
+ slow: {
20
+ key: "slow",
21
+ name: "Slow Tower",
22
+ type: "slow",
23
+ cost: 60,
24
+ range: 8.5,
25
+ fireRate: 0.9,
26
+ damage: 4,
27
+ // On-hit slow: 40% slow (mult 0.6) for 2.5s, refresh on re-hit
28
+ projectileEffect: {
29
+ type: "slow",
30
+ mult: 0.6,
31
+ duration: 2.5,
32
+ color: 0x80d8ff,
33
+ emissive: 0x104a70,
34
+ },
35
+ },
36
+ sniper: {
37
+ key: "sniper",
38
+ name: "Sniper Tower",
39
+ type: "sniper",
40
+ cost: 130,
41
+ // 2x the range of basic
42
+ range: 18,
43
+ // slow fire rate
44
+ fireRate: 0.7, // shots per second (reload ≈ 3.33s)
45
+ damage: 32, // high damage per shot
46
+ // Sniper specific parameters
47
+ aimTime: 0.8, // seconds to aim before firing
48
+ projectileSpeed: 40, // high-speed dart
49
+ // Targeting priority identifier
50
+ targetPriority: "closestToExit",
51
+ // If target moves out of this threshold (>= range) during aiming, cancel
52
+ cancelThreshold: 18,
53
+ // Optional chance to pierce; behavior can be extended later
54
+ pierceChance: 0.0,
55
+ },
56
+ electric: {
57
+ key: "electric",
58
+ name: "Electric Tower",
59
+ type: "electric",
60
+ cost: 200,
61
+ // Set to the smallest tower range (slow tower = 8.5)
62
+ range: 8.5, // world units (scene uses world units; 1 cell = 2 units, so ~4.25 cells)
63
+ fireRate: 1.0, // shots per second
64
+ damage: 25, // per target
65
+ maxTargets: 3,
66
+ // Persist electric arc visuals for 2 seconds
67
+ arcDurationMs: 2000,
68
+ // Visual tuning for arcs (can be read by Tower)
69
+ arc: {
70
+ color: 0x9ad6ff, // light electric blue
71
+ coreColor: 0xe6fbff,
72
+ thickness: 2,
73
+ jitter: 0.25,
74
+ segments: 10,
75
+ },
76
+ // Targeting priority
77
+ targetPriority: "closestToExit",
78
+ },
79
+ };
80
+ // Back-compat alias for existing code that expects TOWER_CONFIG
81
+ export const TOWER_CONFIG = TOWER_TYPES.basic;
82
+
83
+ // Projectile settings
84
+ export const PROJECTILE_SPEED = 18;
85
+
86
+ // Upgrade settings
87
+ export const UPGRADE_MAX_LEVEL = 5;
88
+ export const UPGRADE_START_COST = 40;
89
+ export const UPGRADE_COST_SCALE = 1.6;
90
+ export const UPGRADE_RANGE_SCALE = 1.1; // +10% per level
91
+ export const UPGRADE_RATE_SCALE = 1.15; // +15% per level
92
+ export const UPGRADE_DAMAGE_SCALE = 1.12; // +12% per level
93
+ export const SELL_REFUND_RATE = 0.7;
94
+
95
+ // Infinite wave scaling configuration (replaces static WAVES array)
96
+ export const WAVE_SCALING = {
97
+ // base values (wave 1)
98
+ baseCount: 8,
99
+ baseHP: 12,
100
+ baseSpeed: 3.0,
101
+ baseReward: 5,
102
+ baseSpawnInterval: 0.8,
103
+
104
+ // per-wave growth (applied from wave 2 onward)
105
+ countPerWave: 3, // +3 enemies each wave
106
+ hpMultiplierPerWave: 1.15, // HP *= 1.18 each wave
107
+ speedIncrementPerWave: 0.025, // +0.05 speed per wave
108
+ rewardMultiplierPerWave: 1.03, // reward *= 1.03 each wave
109
+ spawnIntervalDecayPerWave: 0.01, // -0.02s per wave
110
+
111
+ // safeguards/limits
112
+ minSpawnInterval: 0.3,
113
+ maxSpeed: 5.5,
114
+ roundCountToInt: true,
115
+ };
116
+
117
+ // Compute wave parameters for a given wave number (1-based)
118
+ export function getWaveParams(n) {
119
+ const w = WAVE_SCALING;
120
+ const waveNum = Math.max(1, Math.floor(n));
121
+
122
+ const countBase = w.baseCount + (waveNum - 1) * w.countPerWave;
123
+ const count = w.roundCountToInt
124
+ ? Math.max(1, Math.round(countBase))
125
+ : Math.max(1, countBase);
126
+
127
+ const hp = Math.max(
128
+ 1,
129
+ Math.round(w.baseHP * Math.pow(w.hpMultiplierPerWave, waveNum - 1))
130
+ );
131
+
132
+ const speed = Math.min(
133
+ w.maxSpeed,
134
+ w.baseSpeed + (waveNum - 1) * w.speedIncrementPerWave
135
+ );
136
+
137
+ const reward = Math.max(
138
+ 1,
139
+ Math.round(w.baseReward * Math.pow(w.rewardMultiplierPerWave, waveNum - 1))
140
+ );
141
+
142
+ const spawnInterval = Math.max(
143
+ w.minSpawnInterval,
144
+ w.baseSpawnInterval - (waveNum - 1) * w.spawnIntervalDecayPerWave
145
+ );
146
+
147
+ return { count, hp, speed, reward, spawnInterval };
148
+ }
149
+
150
+ // Path waypoints
151
+ export const PATH_POINTS = [
152
+ new THREE.Vector3(-24, 0, -24),
153
+ new THREE.Vector3(-24, 0, 0),
154
+ new THREE.Vector3(0, 0, 0),
155
+ new THREE.Vector3(0, 0, 16),
156
+ new THREE.Vector3(20, 0, 16),
157
+ new THREE.Vector3(26, 0, 26),
158
+ ];
159
+
160
+ // Grid settings
161
+ export const GROUND_SIZE = 60;
162
+ export const GRID_CELL_SIZE = 2;
163
+
164
+ // Visual settings
165
+ export const SCENE_BACKGROUND = 0x202432;
166
+
167
+ // Road settings
168
+ export const ROAD_HALF_WIDTH = 1.5;
169
+ export const ROAD_BEVEL_SIZE = 1.2;
170
+ export const ROAD_ARC_SEGMENTS = 16;
src/entities/Enemy.js ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as THREE from "three";
2
+
3
+ export class Enemy {
4
+ constructor(hp, speed, reward, pathPoints, scene) {
5
+ this.hp = hp;
6
+ this.maxHp = hp;
7
+ // Keep original speed as baseSpeed; speed becomes derived
8
+ this.baseSpeed = speed;
9
+ this.reward = reward;
10
+ this.currentSeg = 0;
11
+ this.pathPoints = pathPoints;
12
+ this.scene = scene;
13
+ this.position = pathPoints[0].clone();
14
+ this.target = pathPoints[1].clone();
15
+
16
+ // Slow status (non-stacking, refreshes on re-hit)
17
+ this.slowMult = 1.0; // 0.6 means 40% slow
18
+ this.slowRemaining = 0.0; // seconds remaining
19
+
20
+ // Mesh
21
+ const geo = new THREE.ConeGeometry(0.6, 1.6, 6);
22
+ const mat = new THREE.MeshStandardMaterial({
23
+ color: 0xff5555,
24
+ roughness: 0.7,
25
+ });
26
+ const mesh = new THREE.Mesh(geo, mat);
27
+ mesh.castShadow = true;
28
+ mesh.position.copy(this.position);
29
+ mesh.rotation.x = Math.PI;
30
+
31
+ // Health bar
32
+ const hbBgGeo = new THREE.PlaneGeometry(1.2, 0.15);
33
+ const hbBgMat = new THREE.MeshBasicMaterial({
34
+ color: 0x000000,
35
+ side: THREE.DoubleSide,
36
+ depthWrite: false,
37
+ depthTest: false, // ensure bar not occluded by ground
38
+ transparent: true,
39
+ opacity: 0.8,
40
+ });
41
+ const hbBg = new THREE.Mesh(hbBgGeo, hbBgMat);
42
+ // Lift the bar higher so it's clearly above the enemy and ground
43
+ // Keep it centered in local Z; we'll face it to camera each frame
44
+ hbBg.position.set(0, 2.0, 0.0);
45
+ // Remove fixed -90deg pitch; use camera-facing billboard instead
46
+ hbBg.rotation.set(0, 0, 0);
47
+ // Billboard: always face the active camera
48
+ hbBg.onBeforeRender = (renderer, scene, camera) => {
49
+ hbBg.quaternion.copy(camera.quaternion);
50
+ };
51
+
52
+ const hbGeo = new THREE.PlaneGeometry(1.2, 0.15);
53
+ const hbMat = new THREE.MeshBasicMaterial({
54
+ color: 0x00ff00,
55
+ side: THREE.DoubleSide,
56
+ depthWrite: false,
57
+ depthTest: false, // ensure bar not occluded by ground
58
+ transparent: true,
59
+ opacity: 0.95,
60
+ });
61
+ const hb = new THREE.Mesh(hbGeo, hbMat);
62
+ // Slight offset to avoid z-fighting with bg
63
+ hb.position.set(0, 0.002, 0);
64
+ hbBg.add(hb);
65
+ mesh.add(hbBg);
66
+
67
+ // Ensure bars render above the enemy and ground
68
+ mesh.renderOrder = 1;
69
+ hbBg.renderOrder = 2000;
70
+ hb.renderOrder = 2001;
71
+
72
+ this.mesh = mesh;
73
+ this.hbBg = hbBg;
74
+ this.hb = hb;
75
+
76
+ // For validation: briefly show bars at spawn so we can confirm visibility.
77
+ // This will be overridden as soon as takeDamage() runs or update() enforces state.
78
+ this.hbBg.visible = true;
79
+
80
+ scene.add(mesh);
81
+ }
82
+
83
+ takeDamage(dmg) {
84
+ this.hp -= dmg;
85
+ this.hp = Math.max(this.hp, 0);
86
+ const ratio = Math.max(0, Math.min(1, this.hp / this.maxHp));
87
+ this.hb.scale.x = ratio;
88
+ this.hb.position.x = -0.6 * (1 - ratio) + 0; // anchor left
89
+
90
+ // Show bar only when not at full health and still alive
91
+ this.hbBg.visible = this.hp > 0 && this.hp < this.maxHp;
92
+ }
93
+
94
+ applySlow(mult, duration) {
95
+ // Non-stacking: overwrite multiplier and refresh duration
96
+ this.slowMult = mult;
97
+ this.slowRemaining = duration;
98
+ }
99
+
100
+ isDead() {
101
+ return this.hp <= 0;
102
+ }
103
+
104
+ update(dt) {
105
+ // Tick slow timer
106
+ if (this.slowRemaining > 0) {
107
+ this.slowRemaining -= dt;
108
+ if (this.slowRemaining <= 0) {
109
+ this.slowRemaining = 0;
110
+ this.slowMult = 1.0;
111
+ }
112
+ }
113
+
114
+ const toTarget = new THREE.Vector3().subVectors(this.target, this.position);
115
+ const dist = toTarget.length();
116
+ const epsilon = 0.01;
117
+
118
+ if (dist < epsilon) {
119
+ // Advance to next waypoint
120
+ this.currentSeg++;
121
+ if (this.currentSeg >= this.pathPoints.length - 1) {
122
+ // Reached end
123
+ return "end";
124
+ }
125
+ this.position.copy(this.target);
126
+ this.target = this.pathPoints[this.currentSeg + 1].clone();
127
+ } else {
128
+ toTarget.normalize();
129
+ const effectiveSpeed =
130
+ this.baseSpeed * (this.slowRemaining > 0 ? this.slowMult : 1.0);
131
+ this.position.addScaledVector(toTarget, effectiveSpeed * dt);
132
+ }
133
+
134
+ this.mesh.position.copy(this.position);
135
+
136
+ // Keep health bar visibility consistent (in case hp changes elsewhere)
137
+ if (this.hbBg) {
138
+ // Only show when damaged; if you don't see bars, they will appear after first damage.
139
+ this.hbBg.visible = this.hp > 0 && this.hp < this.maxHp;
140
+ }
141
+
142
+ // Face movement direction
143
+ if (toTarget.lengthSq() > 0.0001) {
144
+ const angle = Math.atan2(
145
+ this.target.x - this.position.x,
146
+ this.target.z - this.position.z
147
+ );
148
+ this.mesh.rotation.y = angle;
149
+ }
150
+
151
+ return "ok";
152
+ }
153
+
154
+ destroy() {
155
+ this.scene.remove(this.mesh);
156
+ }
157
+ }
src/entities/Projectile.js ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as THREE from "three";
2
+
3
+ export class Projectile {
4
+ constructor(pos, target, speed, scene, projectileEffect = null) {
5
+ this.position = pos.clone();
6
+ this.target = target;
7
+ this.speed = speed;
8
+ this.scene = scene;
9
+ this.damage = 0; // Will be set by tower
10
+ this.projectileEffect = projectileEffect;
11
+
12
+ const geo = new THREE.SphereGeometry(0.15, 8, 8);
13
+ const mat = new THREE.MeshStandardMaterial({
14
+ color:
15
+ projectileEffect && projectileEffect.color
16
+ ? projectileEffect.color
17
+ : 0xffe082,
18
+ emissive:
19
+ projectileEffect && projectileEffect.emissive
20
+ ? projectileEffect.emissive
21
+ : 0x553300,
22
+ });
23
+ const mesh = new THREE.Mesh(geo, mat);
24
+ mesh.castShadow = true;
25
+ mesh.position.copy(this.position);
26
+
27
+ this.mesh = mesh;
28
+ scene.add(mesh);
29
+ this.alive = true;
30
+ }
31
+
32
+ update(dt, spawnHitEffect) {
33
+ if (!this.alive) return "dead";
34
+ if (!this.target || this.target.isDead()) {
35
+ this.alive = false;
36
+ return "dead";
37
+ }
38
+
39
+ const toTarget = new THREE.Vector3().subVectors(
40
+ this.target.mesh.position,
41
+ this.position
42
+ );
43
+ const dist = toTarget.length();
44
+
45
+ if (dist < 0.4) {
46
+ this.target.takeDamage(this.damage);
47
+ // Apply on-hit effect if any
48
+ if (
49
+ this.projectileEffect &&
50
+ this.projectileEffect.type === "slow" &&
51
+ this.target.applySlow
52
+ ) {
53
+ const mult = this.projectileEffect.mult ?? 1.0;
54
+ const duration = this.projectileEffect.duration ?? 0;
55
+ this.target.applySlow(mult, duration);
56
+ }
57
+ spawnHitEffect(this.position);
58
+ this.alive = false;
59
+ return "hit";
60
+ }
61
+
62
+ toTarget.normalize();
63
+ this.position.addScaledVector(toTarget, this.speed * dt);
64
+ this.mesh.position.copy(this.position);
65
+
66
+ return "ok";
67
+ }
68
+
69
+ destroy() {
70
+ this.scene.remove(this.mesh);
71
+ }
72
+ }
src/entities/Tower.js ADDED
@@ -0,0 +1,1140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ }
src/game/GameState.js ADDED
@@ -0,0 +1,231 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ INITIAL_MONEY,
3
+ INITIAL_LIVES,
4
+ getWaveParams,
5
+ } from "../config/gameConfig.js";
6
+
7
+ /**
8
+ * Minimal event emitter for local use.
9
+ * Backward-compatible and small-footprint; no external deps.
10
+ */
11
+ class SimpleEventEmitter {
12
+ constructor() {
13
+ this._events = new Map();
14
+ }
15
+
16
+ on(type, handler) {
17
+ if (!this._events.has(type)) this._events.set(type, new Set());
18
+ this._events.get(type).add(handler);
19
+ }
20
+
21
+ off(type, handler) {
22
+ const set = this._events.get(type);
23
+ if (!set) return;
24
+ set.delete(handler);
25
+ if (set.size === 0) this._events.delete(type);
26
+ }
27
+
28
+ emit(type, ...args) {
29
+ const set = this._events.get(type);
30
+ if (!set) return;
31
+ // Copy to array to avoid mutation issues during emit
32
+ [...set].forEach((h) => {
33
+ try {
34
+ h(...args);
35
+ } catch {
36
+ // swallow to avoid breaking game loop
37
+ }
38
+ });
39
+ }
40
+ }
41
+
42
+ export class GameState {
43
+ constructor() {
44
+ // Internal event bus
45
+ this._events = new SimpleEventEmitter();
46
+ this.reset();
47
+ }
48
+
49
+ /**
50
+ * Subscribe to GameState events.
51
+ * Usage: gameState.on('moneyChanged', (newMoney, prevMoney) => {})
52
+ */
53
+ on(type, handler) {
54
+ this._events.on(type, handler);
55
+ }
56
+
57
+ /**
58
+ * Unsubscribe from GameState events.
59
+ */
60
+ off(type, handler) {
61
+ this._events.off(type, handler);
62
+ }
63
+
64
+ /**
65
+ * Convenience subscription helpers for moneyChanged event.
66
+ */
67
+ subscribeMoneyChanged(handler) {
68
+ this.on("moneyChanged", handler);
69
+ }
70
+
71
+ unsubscribeMoneyChanged(handler) {
72
+ this.off("moneyChanged", handler);
73
+ }
74
+
75
+ reset() {
76
+ this.money = INITIAL_MONEY;
77
+ this.lives = INITIAL_LIVES;
78
+ this.waveIndex = 0; // 0-based; wave number = waveIndex + 1
79
+ this.gameOver = false;
80
+ this.gameWon = false; // no longer used for wave completion, kept for compatibility
81
+ this.totalWaves = Infinity; // for compatibility with any UI that reads it
82
+
83
+ // Wave spawning state (accumulator-based; in seconds)
84
+ this.lastSpawnTime = 0; // kept for compatibility but unused by new accumulator
85
+ this.spawnAccum = 0;
86
+ this.spawnedThisWave = 0;
87
+ this.waveActive = false;
88
+
89
+ // Gameplay speed (1x or 2x)
90
+ this.gameSpeed = 1;
91
+
92
+ // Entity arrays
93
+ this.enemies = [];
94
+ this.towers = [];
95
+ this.projectiles = [];
96
+
97
+ // Selection state
98
+ this.selectedTower = null;
99
+ }
100
+
101
+ setGameSpeed(speed) {
102
+ const s = speed === 2 ? 2 : 1;
103
+ this.gameSpeed = s;
104
+ }
105
+
106
+ getGameSpeed() {
107
+ return this.gameSpeed;
108
+ }
109
+
110
+ startWave() {
111
+ if (this.gameOver) return false;
112
+ this.waveActive = true;
113
+ // reset accumulator timing
114
+ this.lastSpawnTime = performance.now() / 1000;
115
+ this.spawnAccum = 0;
116
+ this.spawnedThisWave = 0;
117
+ return true;
118
+ }
119
+
120
+ getCurrentWave() {
121
+ // wave number is 1-based
122
+ const waveNum = this.waveIndex + 1;
123
+ return getWaveParams(waveNum);
124
+ }
125
+
126
+ nextWave() {
127
+ this.waveIndex++;
128
+ // prepare next wave accumulator and state
129
+ this.spawnAccum = 0;
130
+ this.spawnedThisWave = 0;
131
+ this.waveActive = false;
132
+ // never set gameWon due to infinite waves
133
+ }
134
+
135
+ takeDamage(amount = 1) {
136
+ this.lives -= amount;
137
+ if (this.lives <= 0) {
138
+ this.gameOver = true;
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Increment money and emit moneyChanged if value changed.
144
+ */
145
+ addMoney(amount) {
146
+ if (!amount) return;
147
+ const prev = this.money;
148
+ this.money += amount;
149
+ if (this.money !== prev) {
150
+ this._events.emit("moneyChanged", this.money, prev);
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Spend money if enough funds; emits moneyChanged on success.
156
+ * Returns true if spent, false otherwise.
157
+ */
158
+ spendMoney(amount) {
159
+ if (this.money >= amount) {
160
+ const prev = this.money;
161
+ this.money -= amount;
162
+ if (this.money !== prev) {
163
+ this._events.emit("moneyChanged", this.money, prev);
164
+ }
165
+ return true;
166
+ }
167
+ return false;
168
+ }
169
+
170
+ /**
171
+ * Set absolute money value; emits moneyChanged if changed.
172
+ */
173
+ setMoney(amount) {
174
+ const prev = this.money;
175
+ this.money = amount;
176
+ if (this.money !== prev) {
177
+ this._events.emit("moneyChanged", this.money, prev);
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Returns whether there is enough money for amount.
183
+ */
184
+ canAfford(amount) {
185
+ return this.money >= amount;
186
+ }
187
+
188
+ // TODO(deprecation): Avoid direct assignments to `money` outside GameState.
189
+ // Migrate any external direct writes to use addMoney/spendMoney/setMoney.
190
+
191
+ addEnemy(enemy) {
192
+ this.enemies.push(enemy);
193
+ }
194
+
195
+ removeEnemy(enemy) {
196
+ const index = this.enemies.indexOf(enemy);
197
+ if (index > -1) {
198
+ this.enemies.splice(index, 1);
199
+ }
200
+ }
201
+
202
+ addTower(tower) {
203
+ this.towers.push(tower);
204
+ }
205
+
206
+ removeTower(tower) {
207
+ const index = this.towers.indexOf(tower);
208
+ if (index > -1) {
209
+ this.towers.splice(index, 1);
210
+ }
211
+ }
212
+
213
+ addProjectile(projectile) {
214
+ this.projectiles.push(projectile);
215
+ }
216
+
217
+ removeProjectile(projectile) {
218
+ const index = this.projectiles.indexOf(projectile);
219
+ if (index > -1) {
220
+ this.projectiles.splice(index, 1);
221
+ }
222
+ }
223
+
224
+ setSelectedTower(tower) {
225
+ this.selectedTower = tower;
226
+ }
227
+
228
+ isGameActive() {
229
+ return !this.gameOver;
230
+ }
231
+ }
src/main.js ADDED
@@ -0,0 +1,592 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as THREE from "three";
2
+ import { SceneSetup } from "./scene/SceneSetup.js";
3
+ import { PathBuilder } from "./scene/PathBuilder.js";
4
+ import { GameState } from "./game/GameState.js";
5
+ import { UIManager } from "./ui/UIManager.js";
6
+ import { Enemy } from "./entities/Enemy.js";
7
+ import { Tower } from "./entities/Tower.js";
8
+ import {
9
+ TOWER_TYPES,
10
+ PATH_POINTS,
11
+ PROJECTILE_SPEED,
12
+ GRID_CELL_SIZE,
13
+ } from "./config/gameConfig.js";
14
+ import {
15
+ snapToGrid,
16
+ isOnRoad,
17
+ EffectSystem,
18
+ worldToCell,
19
+ cellToWorldCenter,
20
+ } from "./utils/utils.js";
21
+
22
+ // Initialize game components
23
+ const sceneSetup = new SceneSetup();
24
+ const pathBuilder = new PathBuilder(sceneSetup.scene);
25
+ 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
+
32
+ // Initialize UI
33
+ uiManager.setWavesTotal(gameState.totalWaves);
34
+ uiManager.updateHUD(gameState);
35
+ uiManager.setMessage(
36
+ "Click on the ground to place a tower. Press G to toggle grid."
37
+ );
38
+
39
+ // Initialize speed control UI
40
+ if (typeof uiManager.initSpeedControls === "function") {
41
+ uiManager.initSpeedControls(gameState.getGameSpeed());
42
+ }
43
+ if (typeof uiManager.onSpeedChange === "function") {
44
+ uiManager.onSpeedChange((speed) => {
45
+ if (typeof gameState.setGameSpeed === "function") {
46
+ gameState.setGameSpeed(speed);
47
+ } else {
48
+ gameState.gameSpeed = speed === 2 ? 2 : 1;
49
+ }
50
+ if (typeof uiManager.updateSpeedControls === "function") {
51
+ uiManager.updateSpeedControls(
52
+ gameState.getGameSpeed ? gameState.getGameSpeed() : gameState.gameSpeed
53
+ );
54
+ }
55
+ });
56
+ }
57
+
58
+ /**
59
+ * Raycaster and pointer state
60
+ */
61
+ const raycaster = new THREE.Raycaster();
62
+ const mouse = new THREE.Vector2();
63
+ // Track hovered tower for outline toggle
64
+ let hoveredTower = null;
65
+
66
+ // Drag/rotation suppression to avoid triggering click after a drag
67
+ let isPointerDown = false;
68
+ let didDrag = false;
69
+ let downPos = { x: 0, y: 0 };
70
+ // pixels moved to consider it a drag (tuned to ignore minor jitter)
71
+ const DRAG_SUPPRESS_PX = 8;
72
+
73
+ // Hover highlight overlay (cell preview)
74
+ const hoverMaterial = new THREE.MeshBasicMaterial({
75
+ color: 0x3a97ff,
76
+ transparent: true,
77
+ opacity: 0.25,
78
+ depthWrite: false,
79
+ });
80
+ const hoverGeo = new THREE.PlaneGeometry(GRID_CELL_SIZE, GRID_CELL_SIZE);
81
+ const hoverMesh = new THREE.Mesh(hoverGeo, hoverMaterial);
82
+ hoverMesh.rotation.x = -Math.PI / 2;
83
+ hoverMesh.visible = false;
84
+ sceneSetup.scene.add(hoverMesh);
85
+
86
+ // Track last hovered center to reuse on click
87
+ let lastHoveredCenter = null;
88
+
89
+ function updateHover(e) {
90
+ // Track drag distance while pointer is down to suppress click-after-drag
91
+ if (isPointerDown) {
92
+ const dx = e.clientX - downPos.x;
93
+ const dy = e.clientY - downPos.y;
94
+ if (!didDrag && dx * dx + dy * dy >= DRAG_SUPPRESS_PX * DRAG_SUPPRESS_PX) {
95
+ didDrag = true;
96
+ }
97
+ }
98
+ if (!gameState.isGameActive()) {
99
+ hoverMesh.visible = false;
100
+ // clear tower hover when game inactive
101
+ if (hoveredTower) {
102
+ hoveredTower.setHovered(false);
103
+ hoveredTower = null;
104
+ }
105
+ return;
106
+ }
107
+ mouse.x = (e.clientX / window.innerWidth) * 2 - 1;
108
+ mouse.y = -(e.clientY / window.innerHeight) * 2 + 1;
109
+ raycaster.setFromCamera(mouse, sceneSetup.camera);
110
+
111
+ // 1) Tower hover detection via raycast
112
+ const towerMeshes = gameState.towers.map((t) => t.mesh);
113
+ const tHits = towerMeshes.length
114
+ ? raycaster.intersectObjects(towerMeshes, true)
115
+ : [];
116
+ if (tHits.length > 0) {
117
+ const hitObj = tHits[0].object;
118
+ const owner = gameState.towers.find(
119
+ (t) =>
120
+ hitObj === t.mesh ||
121
+ t.mesh.children.includes(hitObj) ||
122
+ t.head === hitObj ||
123
+ t.ring === hitObj ||
124
+ t.mesh.children.some((c) => c === hitObj)
125
+ );
126
+ if (owner) {
127
+ if (hoveredTower && hoveredTower !== owner) {
128
+ hoveredTower.setHovered(false);
129
+ }
130
+ hoveredTower = owner;
131
+ hoveredTower.setHovered(true);
132
+ }
133
+ } else {
134
+ if (hoveredTower) {
135
+ hoveredTower.setHovered(false);
136
+ hoveredTower = null;
137
+ }
138
+ }
139
+
140
+ // 2) Ground hover preview (existing behavior)
141
+ const intersects = raycaster.intersectObjects([sceneSetup.ground], false);
142
+ if (intersects.length === 0) {
143
+ hoverMesh.visible = false;
144
+ lastHoveredCenter = null;
145
+ return;
146
+ }
147
+
148
+ const p = intersects[0].point.clone();
149
+ p.y = 0;
150
+
151
+ // Convert to cell center
152
+ const { col, row } = worldToCell(p.x, p.z, GRID_CELL_SIZE);
153
+ const center = cellToWorldCenter(col, row, GRID_CELL_SIZE);
154
+
155
+ // Determine validity using existing constraints
156
+ const valid = canPlaceTowerAt(center);
157
+
158
+ hoverMesh.position.set(center.x, 0.01, center.z);
159
+ hoverMesh.material.color.setHex(valid ? 0x3a97ff : 0xff5555);
160
+ hoverMesh.visible = true;
161
+
162
+ lastHoveredCenter = center;
163
+ }
164
+
165
+ function canPlaceTowerAt(pos) {
166
+ if (isOnRoad(pos)) {
167
+ uiManager.setMessage("Can't place on the road!");
168
+ return false;
169
+ }
170
+ // Allow edge-adjacent placement: threshold based on grid size
171
+ const minSeparation = 0.9 * GRID_CELL_SIZE;
172
+ for (const t of gameState.towers) {
173
+ if (t.position.distanceTo(pos) < minSeparation) {
174
+ uiManager.setMessage("Too close to another tower.");
175
+ return false;
176
+ }
177
+ }
178
+ return true;
179
+ }
180
+
181
+ // Game functions
182
+ function resetGame() {
183
+ // Clean up entities
184
+ gameState.enemies.forEach((e) => e.destroy());
185
+ gameState.towers.forEach((t) => t.destroy());
186
+ gameState.projectiles.forEach((p) => p.destroy());
187
+
188
+ // Reset game state
189
+ gameState.reset();
190
+
191
+ // Ensure speed is reset to x1 at the start of a new game
192
+ if (typeof gameState.setGameSpeed === "function") {
193
+ gameState.setGameSpeed(1);
194
+ } else {
195
+ gameState.gameSpeed = 1;
196
+ }
197
+ if (typeof uiManager.updateSpeedControls === "function") {
198
+ uiManager.updateSpeedControls(
199
+ gameState.getGameSpeed ? gameState.getGameSpeed() : gameState.gameSpeed
200
+ );
201
+ }
202
+
203
+ // Update UI
204
+ uiManager.setMessage(
205
+ "Click on the ground to place a tower. Press G to toggle grid."
206
+ );
207
+ uiManager.updateHUD(gameState);
208
+ }
209
+
210
+ function spawnEnemy(wave) {
211
+ const enemy = new Enemy(
212
+ wave.hp,
213
+ wave.speed,
214
+ wave.reward,
215
+ PATH_POINTS,
216
+ sceneSetup.scene
217
+ );
218
+ gameState.addEnemy(enemy);
219
+ }
220
+
221
+ function setSelectedTower(tower) {
222
+ // Clear old selection
223
+ if (gameState.selectedTower) {
224
+ gameState.selectedTower.setSelected(false);
225
+ }
226
+
227
+ gameState.setSelectedTower(tower);
228
+
229
+ if (tower) {
230
+ tower.setSelected(true);
231
+ uiManager.showUpgradePanel(tower, gameState.money);
232
+ } else {
233
+ uiManager.hideUpgradePanel();
234
+ }
235
+ }
236
+
237
+ // Event handlers
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
244
+ // Also clear any transient hover
245
+ if (hoveredTower) {
246
+ hoveredTower.setHovered(false);
247
+ hoveredTower = null;
248
+ }
249
+ return;
250
+ }
251
+
252
+ mouse.x = (e.clientX / window.innerWidth) * 2 - 1;
253
+ mouse.y = -(e.clientY / window.innerHeight) * 2 + 1;
254
+ raycaster.setFromCamera(mouse, sceneSetup.camera);
255
+
256
+ // First, try to select a tower
257
+ const towerMeshes = gameState.towers.map((t) => t.mesh);
258
+ const tHits = raycaster.intersectObjects(towerMeshes, true);
259
+
260
+ if (tHits.length > 0) {
261
+ // Find owning tower
262
+ const hit = tHits[0].object;
263
+ const owner = gameState.towers.find(
264
+ (t) =>
265
+ hit === t.mesh ||
266
+ t.mesh.children.includes(hit) ||
267
+ t.head === hit ||
268
+ t.ring === hit ||
269
+ t.mesh.children.some((c) => c === hit)
270
+ );
271
+ if (owner) {
272
+ setSelectedTower(owner);
273
+ return;
274
+ }
275
+ }
276
+
277
+ // Otherwise, handle ground placement and deselection
278
+ const intersects = raycaster.intersectObjects([sceneSetup.ground], false);
279
+ if (intersects.length > 0) {
280
+ const p = intersects[0].point.clone();
281
+ p.y = 0;
282
+
283
+ // Deselect if clicking ground without Shift
284
+ if (!e.shiftKey) {
285
+ setSelectedTower(null);
286
+ }
287
+
288
+ // Compute exact cell center instead of intersection
289
+ const { col, row } = worldToCell(p.x, p.z, GRID_CELL_SIZE);
290
+ const pCenter = cellToWorldCenter(col, row, GRID_CELL_SIZE);
291
+
292
+ // Place constraints based on center
293
+ if (!canPlaceTowerAt(pCenter)) {
294
+ return;
295
+ }
296
+
297
+ // Build palette options based on affordability
298
+ const opts = [
299
+ {
300
+ key: "basic",
301
+ name: TOWER_TYPES.basic.name,
302
+ cost: TOWER_TYPES.basic.cost,
303
+ enabled: gameState.canAfford(TOWER_TYPES.basic.cost),
304
+ desc: "Balanced tower",
305
+ color: "#3a97ff",
306
+ },
307
+ {
308
+ key: "slow",
309
+ name: TOWER_TYPES.slow.name,
310
+ cost: TOWER_TYPES.slow.cost,
311
+ enabled: gameState.canAfford(TOWER_TYPES.slow.cost),
312
+ desc: "On-hit slow for 2.5s",
313
+ color: "#2fa8ff",
314
+ },
315
+ {
316
+ key: "sniper",
317
+ name: TOWER_TYPES.sniper.name,
318
+ cost: TOWER_TYPES.sniper.cost,
319
+ enabled: gameState.canAfford(TOWER_TYPES.sniper.cost),
320
+ desc: "Long range, slow fire, high damage; aims before firing",
321
+ color: "#ff3b30",
322
+ },
323
+ // New Electric tower option
324
+ ...(TOWER_TYPES.electric
325
+ ? [
326
+ {
327
+ key: "electric",
328
+ name: TOWER_TYPES.electric.name,
329
+ cost: TOWER_TYPES.electric.cost,
330
+ enabled: gameState.canAfford(TOWER_TYPES.electric.cost),
331
+ desc: "Electric arcs hit up to 3 enemies",
332
+ color: "#9ad6ff",
333
+ },
334
+ ]
335
+ : []),
336
+ ];
337
+
338
+ // Show palette near click
339
+ uiManager.showTowerPalette(e.clientX, e.clientY, opts);
340
+
341
+ // One-time handlers
342
+ const handleSelect = (key) => {
343
+ const def = TOWER_TYPES[key];
344
+ if (!def) return;
345
+ // Re-validate position and funds at selection time
346
+ if (!canPlaceTowerAt(pCenter)) return;
347
+ if (!gameState.canAfford(def.cost)) {
348
+ uiManager.setMessage("Not enough money!");
349
+ return;
350
+ }
351
+ gameState.spendMoney(def.cost);
352
+ uiManager.updateHUD(gameState);
353
+ const tower = new Tower(pCenter, def, sceneSetup.scene);
354
+ gameState.addTower(tower);
355
+ uiManager.setMessage(`${def.name} placed!`);
356
+ };
357
+ const handleCancel = () => {
358
+ // No-op, message can be preserved
359
+ };
360
+
361
+ uiManager.onPaletteSelect((key) => handleSelect(key));
362
+ uiManager.onPaletteCancel(() => handleCancel());
363
+ } else {
364
+ setSelectedTower(null);
365
+ }
366
+ // clear any hover state after processing click (prevents stuck outline)
367
+ if (hoveredTower) {
368
+ hoveredTower.setHovered(false);
369
+ hoveredTower = null;
370
+ }
371
+ }
372
+
373
+ /**
374
+ * Pointer handlers to detect drags (used to suppress click after rotate/pan)
375
+ */
376
+ function onMouseDown(e) {
377
+ // Only consider primary or secondary buttons; ignore other cases
378
+ isPointerDown = true;
379
+ didDrag = false;
380
+ downPos.x = e.clientX;
381
+ downPos.y = e.clientY;
382
+ }
383
+
384
+ function onMouseUp() {
385
+ // End drag tracking; let click handler run, which will check didDrag
386
+ isPointerDown = false;
387
+ }
388
+
389
+ // Setup event listeners
390
+ window.addEventListener("click", onClick);
391
+ window.addEventListener("mousemove", updateHover);
392
+ window.addEventListener("mousedown", onMouseDown);
393
+ window.addEventListener("mouseup", onMouseUp);
394
+
395
+ // Arrow key state tracking for smooth camera movement
396
+ const keyState = {
397
+ ArrowUp: false,
398
+ ArrowDown: false,
399
+ ArrowLeft: false,
400
+ ArrowRight: false,
401
+ };
402
+
403
+ window.addEventListener("keydown", (e) => {
404
+ if (e.key === "Escape") {
405
+ setSelectedTower(null);
406
+ }
407
+ if (e.key === "g" || e.key === "G") {
408
+ sceneSetup.grid.visible = !sceneSetup.grid.visible;
409
+ uiManager.setMessage(sceneSetup.grid.visible ? "Grid on" : "Grid off");
410
+ }
411
+ if (e.key in keyState) {
412
+ keyState[e.key] = true;
413
+ }
414
+ });
415
+
416
+ window.addEventListener("keyup", (e) => {
417
+ if (e.key in keyState) {
418
+ keyState[e.key] = false;
419
+ }
420
+ });
421
+
422
+ uiManager.onRestartClick(() => resetGame());
423
+
424
+ uiManager.onUpgradeClick(() => {
425
+ const tower = gameState.selectedTower;
426
+ if (!tower) return;
427
+
428
+ if (!tower.canUpgrade) {
429
+ uiManager.setMessage("Tower is at max level.");
430
+ uiManager.showUpgradePanel(tower, gameState.money);
431
+ return;
432
+ }
433
+
434
+ if (!gameState.canAfford(tower.nextUpgradeCost)) {
435
+ uiManager.setMessage("Not enough money to upgrade.");
436
+ uiManager.showUpgradePanel(tower, gameState.money);
437
+ return;
438
+ }
439
+
440
+ gameState.spendMoney(tower.nextUpgradeCost);
441
+ const ok = tower.upgrade();
442
+ if (ok) {
443
+ uiManager.updateHUD(gameState);
444
+ uiManager.setMessage("Tower upgraded.");
445
+ uiManager.showUpgradePanel(tower, gameState.money);
446
+ }
447
+ });
448
+
449
+ uiManager.onSellClick(() => {
450
+ const tower = gameState.selectedTower;
451
+ if (!tower) return;
452
+
453
+ const refund = tower.getSellValue();
454
+ gameState.addMoney(refund);
455
+ uiManager.updateHUD(gameState);
456
+
457
+ tower.destroy();
458
+ gameState.removeTower(tower);
459
+ uiManager.setMessage(`Tower sold for ${refund}.`);
460
+ setSelectedTower(null);
461
+ });
462
+
463
+ // Wave spawning
464
+ function updateSpawning(dt) {
465
+ if (!gameState.isGameActive()) return;
466
+
467
+ const wave = gameState.getCurrentWave();
468
+ if (!wave) return;
469
+
470
+ // Accumulate scaled time and spawn at intervals
471
+ if (gameState.spawnedThisWave < wave.count) {
472
+ gameState.spawnAccum += dt;
473
+ while (
474
+ gameState.spawnedThisWave < wave.count &&
475
+ gameState.spawnAccum >= wave.spawnInterval
476
+ ) {
477
+ spawnEnemy(wave);
478
+ gameState.spawnedThisWave++;
479
+ gameState.spawnAccum -= wave.spawnInterval;
480
+ }
481
+ } else {
482
+ // Wait until all enemies are cleared to progress
483
+ if (gameState.enemies.length === 0) {
484
+ gameState.nextWave();
485
+ uiManager.updateHUD(gameState);
486
+
487
+ // Infinite waves: always start the next wave
488
+ if (gameState.startWave()) {
489
+ uiManager.setMessage(`Wave ${gameState.waveIndex + 1} started!`);
490
+ }
491
+ }
492
+ }
493
+ }
494
+
495
+ // Main game loop
496
+ let lastTime = performance.now() / 1000;
497
+
498
+ function animate() {
499
+ requestAnimationFrame(animate);
500
+
501
+ const now = performance.now() / 1000;
502
+ const dtRaw = Math.min(0.05, now - lastTime);
503
+ lastTime = now;
504
+
505
+ // Scaled gameplay dt based on GameState speed
506
+ const speed =
507
+ typeof gameState.getGameSpeed === "function"
508
+ ? gameState.getGameSpeed()
509
+ : gameState.gameSpeed || 1;
510
+ const dt = dtRaw * speed;
511
+
512
+ // Camera movement via arrow keys should remain unscaled for consistent navigation
513
+ const moveDir = { x: 0, z: 0 };
514
+ if (keyState.ArrowUp) moveDir.z -= 1;
515
+ if (keyState.ArrowDown) moveDir.z += 1;
516
+ if (keyState.ArrowLeft) moveDir.x -= 1;
517
+ if (keyState.ArrowRight) moveDir.x += 1;
518
+ if (moveDir.x !== 0 || moveDir.z !== 0) {
519
+ sceneSetup.moveCamera(moveDir, dtRaw);
520
+ }
521
+
522
+ if (gameState.isGameActive()) {
523
+ // Spawning must use scaled dt to respect speed
524
+ updateSpawning(dt);
525
+
526
+ // Update enemies
527
+ for (let i = gameState.enemies.length - 1; i >= 0; i--) {
528
+ const enemy = gameState.enemies[i];
529
+ const status = enemy.update(dt);
530
+
531
+ if (enemy.isDead()) {
532
+ gameState.addMoney(enemy.reward);
533
+ uiManager.updateHUD(gameState);
534
+ enemy.destroy();
535
+ gameState.removeEnemy(enemy);
536
+ continue;
537
+ }
538
+
539
+ if (status === "end") {
540
+ gameState.takeDamage(1);
541
+ uiManager.updateHUD(gameState);
542
+ enemy.destroy();
543
+ gameState.removeEnemy(enemy);
544
+
545
+ if (gameState.gameOver) {
546
+ uiManager.setMessage("Game Over! Enemies broke through.");
547
+ }
548
+ }
549
+ }
550
+
551
+ // Update towers
552
+ for (const tower of gameState.towers) {
553
+ tower.tryFire(
554
+ dt,
555
+ gameState.enemies,
556
+ gameState.projectiles,
557
+ PROJECTILE_SPEED
558
+ );
559
+ }
560
+
561
+ // Keep upgrade panel in sync if selected
562
+ if (gameState.selectedTower) {
563
+ uiManager.showUpgradePanel(gameState.selectedTower, gameState.money);
564
+ }
565
+
566
+ // Update projectiles
567
+ for (let i = gameState.projectiles.length - 1; i >= 0; i--) {
568
+ const projectile = gameState.projectiles[i];
569
+ const status = projectile.update(dt, (pos) =>
570
+ effectSystem.spawnHitEffect(pos)
571
+ );
572
+
573
+ if (status !== "ok") {
574
+ projectile.destroy();
575
+ gameState.removeProjectile(projectile);
576
+ }
577
+ }
578
+
579
+ effectSystem.update(dt);
580
+ }
581
+
582
+ sceneSetup.render();
583
+ }
584
+
585
+ // Start the game
586
+ setTimeout(() => {
587
+ if (gameState.startWave()) {
588
+ uiManager.setMessage(`Wave 1 started!`);
589
+ }
590
+ }, 1200);
591
+
592
+ animate();
src/scene/PathBuilder.js ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as THREE from "three";
2
+ import {
3
+ PATH_POINTS,
4
+ ROAD_HALF_WIDTH,
5
+ GRID_CELL_SIZE,
6
+ } from "../config/gameConfig.js";
7
+
8
+ export class PathBuilder {
9
+ constructor(scene) {
10
+ this.scene = scene;
11
+ // Clone and snap path points to the grid to ensure alignment
12
+ this.pathPoints = PATH_POINTS.map((p) => {
13
+ const snapped = p.clone
14
+ ? p.clone()
15
+ : new THREE.Vector3(p.x, p.y ?? 0, p.z);
16
+ snapped.x = Math.round(snapped.x / GRID_CELL_SIZE) * GRID_CELL_SIZE;
17
+ snapped.z = Math.round(snapped.z / GRID_CELL_SIZE) * GRID_CELL_SIZE;
18
+ return snapped;
19
+ });
20
+ this.roadMeshes = [];
21
+
22
+ // Materials
23
+ this.roadMat = new THREE.MeshStandardMaterial({
24
+ color: 0x393c41,
25
+ metalness: 0.1,
26
+ roughness: 0.9,
27
+ });
28
+ }
29
+
30
+ buildPath() {
31
+ // Visualize path line
32
+ this.createPathLine();
33
+
34
+ // Build straight road segments only (no bevels or rounded corners)
35
+ for (let i = 0; i < this.pathPoints.length - 1; i++) {
36
+ this.addSegment(this.pathPoints[i], this.pathPoints[i + 1]);
37
+ }
38
+ }
39
+
40
+ createPathLine() {
41
+ const pathLineMat = new THREE.LineBasicMaterial({ color: 0xffff00 });
42
+ const pathLineGeo = new THREE.BufferGeometry().setFromPoints(
43
+ this.pathPoints
44
+ );
45
+ const pathLine = new THREE.Line(pathLineGeo, pathLineMat);
46
+ pathLine.position.y = 0.01;
47
+ this.scene.add(pathLine);
48
+ }
49
+
50
+ addSegment(a, b) {
51
+ const seg = new THREE.Vector3().subVectors(b, a);
52
+ const len = seg.length();
53
+ if (len <= 0.0001) return;
54
+
55
+ const mid = new THREE.Vector3().addVectors(a, b).multiplyScalar(0.5);
56
+
57
+ const roadGeo = new THREE.BoxGeometry(len, 0.1, ROAD_HALF_WIDTH * 2);
58
+ const road = new THREE.Mesh(roadGeo, this.roadMat);
59
+ road.castShadow = false;
60
+ road.receiveShadow = true;
61
+ road.position.set(mid.x, 0.05, mid.z);
62
+ const angle = Math.atan2(seg.z, seg.x);
63
+ road.rotation.y = -angle;
64
+ this.scene.add(road);
65
+ this.roadMeshes.push(road);
66
+ }
67
+ }
src/scene/SceneSetup.js ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as THREE from "three";
2
+ import { OrbitControls } from "three/addons/controls/OrbitControls.js";
3
+ import {
4
+ SCENE_BACKGROUND,
5
+ GROUND_SIZE,
6
+ GRID_CELL_SIZE,
7
+ } from "../config/gameConfig.js";
8
+
9
+ export class SceneSetup {
10
+ constructor() {
11
+ // Basic scene setup
12
+ this.scene = new THREE.Scene();
13
+ this.scene.background = new THREE.Color(SCENE_BACKGROUND);
14
+
15
+ // Camera
16
+ this.camera = new THREE.PerspectiveCamera(
17
+ 60,
18
+ window.innerWidth / window.innerHeight,
19
+ 0.1,
20
+ 2000
21
+ );
22
+ this.camera.position.set(20, 22, 24);
23
+
24
+ // Renderer
25
+ this.renderer = new THREE.WebGLRenderer({ antialias: true });
26
+ this.renderer.setSize(window.innerWidth, window.innerHeight);
27
+ this.renderer.shadowMap.enabled = true;
28
+ document.body.appendChild(this.renderer.domElement);
29
+
30
+ // Controls
31
+ this.controls = new OrbitControls(this.camera, this.renderer.domElement);
32
+ this.controls.target.set(0, 0, 0);
33
+ this.controls.enableDamping = true;
34
+
35
+ // Camera movement config
36
+ this.CAMERA_MOVE_SPEED = 18; // units per second in world-space
37
+ // Bounds relative to ground size, keep a small margin inside edges
38
+ const half = GROUND_SIZE / 2;
39
+ this.CAMERA_MIN_X = -half + 2;
40
+ this.CAMERA_MAX_X = half - 2;
41
+ this.CAMERA_MIN_Z = -half + 2;
42
+ this.CAMERA_MAX_Z = half - 2;
43
+
44
+ // Setup lighting
45
+ this.setupLighting();
46
+
47
+ // Setup ground
48
+ this.ground = this.setupGround();
49
+
50
+ // Setup grid
51
+ this.grid = this.setupGrid();
52
+
53
+ // Handle window resize
54
+ window.addEventListener("resize", () => this.onWindowResize());
55
+ }
56
+
57
+ setupLighting() {
58
+ const hemi = new THREE.HemisphereLight(0xffffff, 0x404040, 0.6);
59
+ this.scene.add(hemi);
60
+
61
+ const dirLight = new THREE.DirectionalLight(0xffffff, 1.0);
62
+ dirLight.position.set(8, 20, 8);
63
+ dirLight.castShadow = true;
64
+ dirLight.shadow.mapSize.set(1024, 1024);
65
+ this.scene.add(dirLight);
66
+ }
67
+
68
+ setupGround() {
69
+ const groundGeo = new THREE.PlaneGeometry(GROUND_SIZE, GROUND_SIZE);
70
+ const groundMat = new THREE.MeshStandardMaterial({ color: 0x1d6e2f });
71
+ const ground = new THREE.Mesh(groundGeo, groundMat);
72
+ ground.rotation.x = -Math.PI / 2;
73
+ ground.receiveShadow = true;
74
+ ground.name = "ground";
75
+ this.scene.add(ground);
76
+ return ground;
77
+ }
78
+
79
+ setupGrid() {
80
+ const divisions = Math.floor(GROUND_SIZE / GRID_CELL_SIZE);
81
+ const grid = new THREE.GridHelper(
82
+ GROUND_SIZE,
83
+ divisions,
84
+ 0x8ab4f8,
85
+ 0x3a97ff
86
+ );
87
+ grid.position.y = 0.02; // avoid z-fighting with ground
88
+ grid.material.transparent = true;
89
+ grid.material.opacity = 0.35;
90
+ grid.renderOrder = 0;
91
+ this.scene.add(grid);
92
+ return grid;
93
+ }
94
+
95
+ clampToBounds(vec3) {
96
+ vec3.x = Math.min(this.CAMERA_MAX_X, Math.max(this.CAMERA_MIN_X, vec3.x));
97
+ vec3.z = Math.min(this.CAMERA_MAX_Z, Math.max(this.CAMERA_MIN_Z, vec3.z));
98
+ return vec3;
99
+ }
100
+
101
+ // Move camera and controls target horizontally in world space while keeping height
102
+ moveCamera(direction, deltaTime) {
103
+ // direction: {x: -1|0|1, z: -1|0|1}
104
+ if (!direction || (direction.x === 0 && direction.z === 0)) return;
105
+
106
+ // Compute normalized planar direction
107
+ const move = new THREE.Vector3(direction.x, 0, direction.z);
108
+ if (move.lengthSq() === 0) return;
109
+ move.normalize().multiplyScalar(this.CAMERA_MOVE_SPEED * deltaTime);
110
+
111
+ // Maintain current height
112
+ const currentY = this.camera.position.y;
113
+
114
+ // Move both camera and target so orbit feel is preserved
115
+ const newCamPos = this.camera.position.clone().add(move);
116
+ const newTarget = this.controls.target.clone().add(move);
117
+
118
+ // Clamp within bounds
119
+ this.clampToBounds(newCamPos);
120
+ this.clampToBounds(newTarget);
121
+
122
+ // Apply positions (preserve camera height)
123
+ newCamPos.y = currentY;
124
+ this.camera.position.copy(newCamPos);
125
+ this.controls.target.copy(newTarget);
126
+ // Let OrbitControls smoothing handle interpolation
127
+ }
128
+
129
+ onWindowResize() {
130
+ this.camera.aspect = window.innerWidth / window.innerHeight;
131
+ this.camera.updateProjectionMatrix();
132
+ this.renderer.setSize(window.innerWidth, window.innerHeight);
133
+ }
134
+
135
+ render(gameState) {
136
+ // Optionally pass gameState so we can update transient visuals like electric arcs
137
+ this.controls.update();
138
+
139
+ // Per-frame visual updates for towers (electric arcs fade/cleanup)
140
+ if (gameState && Array.isArray(gameState.towers)) {
141
+ const now = performance.now();
142
+ for (const t of gameState.towers) {
143
+ if (t?.updateElectricArcs) {
144
+ t.updateElectricArcs(now);
145
+ }
146
+ }
147
+ }
148
+
149
+ this.renderer.render(this.scene, this.camera);
150
+ }
151
+ }
src/ui/UIManager.js ADDED
@@ -0,0 +1,407 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export class UIManager {
2
+ constructor() {
3
+ // HUD elements
4
+ this.moneyEl = document.getElementById("money");
5
+ this.livesEl = document.getElementById("lives");
6
+ this.waveEl = document.getElementById("wave");
7
+ this.wavesTotalEl = document.getElementById("wavesTotal");
8
+ this.messagesEl = document.getElementById("messages");
9
+ this.restartBtn = document.getElementById("restart");
10
+
11
+ // Upgrade panel elements
12
+ this.upgradePanel = document.getElementById("upgradePanel");
13
+ this.upgradeBtn = document.getElementById("upgradeBtn");
14
+ this.sellBtn = document.getElementById("sellBtn");
15
+ this.tLevelEl = document.getElementById("t_level");
16
+ this.tRangeEl = document.getElementById("t_range");
17
+ this.tRateEl = document.getElementById("t_rate");
18
+ this.tDamageEl = document.getElementById("t_damage");
19
+ this.tNextCostEl = document.getElementById("t_nextCost");
20
+
21
+ // Tower palette (floating)
22
+ this.palette = document.createElement("div");
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;
29
+ if (!this.palette.contains(ev.target)) {
30
+ this.hideTowerPalette();
31
+ if (this._paletteCancelCb) this._paletteCancelCb();
32
+ }
33
+ };
34
+ this._escHandler = (ev) => {
35
+ if (ev.key === "Escape" && this.palette.style.display !== "none") {
36
+ this.hideTowerPalette();
37
+ if (this._paletteCancelCb) this._paletteCancelCb();
38
+ }
39
+ };
40
+ window.addEventListener("mousedown", this._outsideHandler);
41
+ window.addEventListener("keydown", this._escHandler);
42
+
43
+ this._paletteSelectCb = null;
44
+ this._paletteCancelCb = null;
45
+
46
+ // Speed controls (top-right UI)
47
+ this._speedChangeCb = null;
48
+ this.speedContainer = null;
49
+ this.speedBtn1 = null;
50
+ this.speedBtn2 = null;
51
+
52
+ // Game state subscription placeholders
53
+ this._moneyChangedHandler = null;
54
+ this._gameStateForSubscriptions = null;
55
+
56
+ // Sync initial HUD visibility with new CSS classes
57
+ if (this.restartBtn) this.restartBtn.classList.add("hidden");
58
+ if (this.upgradePanel) this.upgradePanel.classList.add("hidden");
59
+ }
60
+
61
+ // Init and update for speed controls
62
+ initSpeedControls(initialSpeed = 1) {
63
+ if (this.speedContainer) return; // already initialized
64
+
65
+ const container = document.createElement("div");
66
+ container.className = "speed-controls";
67
+
68
+ const makeBtn = (label, pressed = false) => {
69
+ const b = document.createElement("button");
70
+ b.textContent = label;
71
+ b.className = "btn btn--toggle";
72
+ b.setAttribute("aria-pressed", pressed ? "true" : "false");
73
+ b.type = "button";
74
+ return b;
75
+ };
76
+
77
+ const b1 = makeBtn("x1", initialSpeed === 1);
78
+ const b2 = makeBtn("x2", initialSpeed === 2);
79
+
80
+ b1.addEventListener("click", (e) => {
81
+ e.stopPropagation();
82
+ this._setActiveSpeed(1);
83
+ if (this._speedChangeCb) this._speedChangeCb(1);
84
+ });
85
+ b2.addEventListener("click", (e) => {
86
+ e.stopPropagation();
87
+ this._setActiveSpeed(2);
88
+ if (this._speedChangeCb) this._speedChangeCb(2);
89
+ });
90
+
91
+ container.appendChild(b1);
92
+ container.appendChild(b2);
93
+ document.body.appendChild(container);
94
+
95
+ this.speedContainer = container;
96
+ this.speedBtn1 = b1;
97
+ this.speedBtn2 = b2;
98
+
99
+ this.updateSpeedControls(initialSpeed);
100
+ }
101
+
102
+ onSpeedChange(callback) {
103
+ this._speedChangeCb = callback;
104
+ }
105
+
106
+ updateSpeedControls(currentSpeed = 1) {
107
+ if (!this.speedBtn1 || !this.speedBtn2) return;
108
+ this.speedBtn1.setAttribute(
109
+ "aria-pressed",
110
+ currentSpeed === 1 ? "true" : "false"
111
+ );
112
+ this.speedBtn2.setAttribute(
113
+ "aria-pressed",
114
+ currentSpeed === 2 ? "true" : "false"
115
+ );
116
+ }
117
+
118
+ _setActiveSpeed(s) {
119
+ this.updateSpeedControls(s);
120
+ }
121
+
122
+ /**
123
+ * Subscribe UI to GameState money changes and perform initial affordability update.
124
+ * Call this once during UI initialization when GameState instance is available.
125
+ */
126
+ initWithGameState(gameState) {
127
+ if (!gameState || this._gameStateForSubscriptions) return;
128
+ this._gameStateForSubscriptions = gameState;
129
+
130
+ // Bind once to keep reference for unsubscription
131
+ this._moneyChangedHandler = (newMoney /*, prevMoney */) => {
132
+ this.updateTowerAffordability(newMoney);
133
+ };
134
+
135
+ // Prefer dedicated helpers if available; fall back to generic on/off
136
+ if (typeof gameState.subscribeMoneyChanged === "function") {
137
+ gameState.subscribeMoneyChanged(this._moneyChangedHandler);
138
+ } else if (typeof gameState.on === "function") {
139
+ gameState.on("moneyChanged", this._moneyChangedHandler);
140
+ }
141
+
142
+ // Initial affordability update using current money
143
+ this.updateTowerAffordability(gameState.money);
144
+ }
145
+
146
+ /**
147
+ * Update HUD labels and also ensure tower affordability matches current money.
148
+ */
149
+ updateHUD(gameState) {
150
+ this.moneyEl.textContent = String(gameState.money);
151
+ this.livesEl.textContent = String(gameState.lives);
152
+
153
+ // Infinite waves: display current wave only
154
+ const currentWave = gameState.waveIndex + 1;
155
+ this.waveEl.textContent = String(currentWave);
156
+
157
+ // If total waves element exists, show infinity symbol
158
+ if (this.wavesTotalEl) {
159
+ this.wavesTotalEl.textContent = "∞";
160
+ }
161
+
162
+ if (gameState.gameOver || gameState.gameWon) {
163
+ this.restartBtn.classList.remove("hidden");
164
+ } else {
165
+ this.restartBtn.classList.add("hidden");
166
+ }
167
+
168
+ // Keep palette/button states in sync with current money on HUD updates too
169
+ this.updateTowerAffordability(gameState.money);
170
+ }
171
+
172
+ setMessage(text) {
173
+ this.messagesEl.textContent = text;
174
+ }
175
+
176
+ setWavesTotal(total) {
177
+ this.wavesTotalEl.textContent = String(total);
178
+ }
179
+
180
+ showUpgradePanel(tower, money) {
181
+ this.upgradePanel.classList.remove("hidden");
182
+ this.tLevelEl.textContent = String(tower.level);
183
+ this.tRangeEl.textContent = tower.range.toFixed(2);
184
+ this.tRateEl.textContent = tower.rate.toFixed(2);
185
+ this.tDamageEl.textContent = tower.damage.toFixed(2);
186
+
187
+ if (tower.canUpgrade) {
188
+ this.tNextCostEl.textContent = String(tower.nextUpgradeCost);
189
+ this.upgradeBtn.disabled = money < tower.nextUpgradeCost;
190
+ } else {
191
+ this.tNextCostEl.textContent = "Max";
192
+ this.upgradeBtn.disabled = true;
193
+ }
194
+
195
+ this.sellBtn.disabled = false;
196
+ }
197
+
198
+ hideUpgradePanel() {
199
+ this.upgradePanel.classList.add("hidden");
200
+ }
201
+
202
+ onRestartClick(callback) {
203
+ this.restartBtn.addEventListener("click", callback);
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
215
+ onPaletteSelect(callback) {
216
+ this._paletteSelectCb = callback;
217
+ }
218
+ onPaletteCancel(callback) {
219
+ this._paletteCancelCb = callback;
220
+ }
221
+
222
+ // Utility: build default palette options using game config
223
+ _defaultTowerOptions() {
224
+ // Lazy import to avoid circular deps in some bundlers
225
+ const { TOWER_TYPES } = require("../config/gameConfig.js");
226
+ const opts = [];
227
+
228
+ const push = (t, extra = {}) => {
229
+ if (!t) return;
230
+ opts.push({
231
+ key: t.key,
232
+ name: t.name,
233
+ cost: t.cost,
234
+ enabled: true,
235
+ desc:
236
+ t.type === "slow"
237
+ ? "Applies slow on hit"
238
+ : t.type === "sniper"
239
+ ? "Long-range high damage"
240
+ : t.type === "electric"
241
+ ? "Electric arcs hit up to 3 enemies"
242
+ : "Basic all-round tower",
243
+ color:
244
+ t.type === "slow"
245
+ ? "#ff69b4"
246
+ : t.type === "sniper"
247
+ ? "#00ffff"
248
+ : t.type === "electric"
249
+ ? "#9ad6ff"
250
+ : "#3a97ff",
251
+ ...extra,
252
+ });
253
+ };
254
+
255
+ push(TOWER_TYPES.basic);
256
+ push(TOWER_TYPES.slow);
257
+ push(TOWER_TYPES.sniper);
258
+ // Ensure Electric shows in palette
259
+ if (TOWER_TYPES.electric) push(TOWER_TYPES.electric);
260
+ return opts;
261
+ }
262
+
263
+ /**
264
+ * Update tower selection UI items (currently palette items) based on affordability.
265
+ * Only adjusts enabled/disabled state and optional 'unaffordable' class.
266
+ */
267
+ updateTowerAffordability(currentMoney) {
268
+ // Current implementation creates palette items dynamically in showTowerPalette.
269
+ // When palette is open, update existing rendered items accordingly.
270
+ const list = this.palette.querySelector(".palette-list");
271
+ if (!list) return;
272
+
273
+ // Read costs from config
274
+ const { TOWER_TYPES } = require("../config/gameConfig.js");
275
+ const costByKey = {
276
+ basic: TOWER_TYPES.basic?.cost,
277
+ slow: TOWER_TYPES.slow?.cost,
278
+ sniper: TOWER_TYPES.sniper?.cost,
279
+ electric: TOWER_TYPES.electric?.cost,
280
+ };
281
+
282
+ // Iterate palette items in DOM (each item corresponds to one tower option)
283
+ const items = list.querySelectorAll(".palette-item");
284
+ items.forEach((item) => {
285
+ // Determine tower key for this item by reading its label text
286
+ // Labels are created as the first span with the tower name
287
+ const labelSpan = item.querySelector("span:first-child");
288
+ const name = labelSpan ? labelSpan.textContent : "";
289
+ // Map name back to key via config
290
+ let key = null;
291
+ for (const k of Object.keys(TOWER_TYPES)) {
292
+ if (TOWER_TYPES[k]?.name === name) {
293
+ key = k;
294
+ break;
295
+ }
296
+ }
297
+ if (!key) return;
298
+
299
+ const cost = costByKey[key];
300
+ const affordable =
301
+ typeof cost === "number" ? cost <= currentMoney : false;
302
+
303
+ // Disable/enable via aria-disabled like current structure uses
304
+ if (affordable) {
305
+ item.removeAttribute("aria-disabled");
306
+ item.classList.remove("unaffordable");
307
+ } else {
308
+ item.setAttribute("aria-disabled", "true");
309
+ // Optional class; safe to add/remove if styles define it
310
+ item.classList.add("unaffordable");
311
+ }
312
+ });
313
+ }
314
+
315
+ showTowerPalette(screenX, screenY, options) {
316
+ // options: [{key, name, cost, enabled, desc, color}]
317
+ this.palette.innerHTML = "";
318
+
319
+ const title = document.createElement("div");
320
+ title.textContent = "Choose a tower";
321
+ title.className = "palette-title";
322
+ this.palette.appendChild(title);
323
+
324
+ // If no options provided, build from config (includes Electric)
325
+ const opts =
326
+ Array.isArray(options) && options.length > 0
327
+ ? options
328
+ : this._defaultTowerOptions();
329
+
330
+ const list = document.createElement("div");
331
+ list.className = "palette-list";
332
+
333
+ opts.forEach((opt) => {
334
+ const item = document.createElement("div");
335
+ item.className = "palette-item";
336
+ if (!opt.enabled) {
337
+ item.setAttribute("aria-disabled", "true");
338
+ }
339
+ const label = document.createElement("span");
340
+ label.textContent = opt.name;
341
+
342
+ const cost = document.createElement("span");
343
+ cost.textContent = `${opt.cost}${opt.key === "electric" ? " ⚡" : ""}`;
344
+ cost.style.fontFamily = "var(--font-mono)";
345
+
346
+ if (opt.color) {
347
+ item.style.boxShadow = `inset 0 0 0 2px ${opt.color}33`;
348
+ }
349
+ item.title = opt.desc || "";
350
+ item.addEventListener("click", (e) => {
351
+ e.stopPropagation();
352
+ // Rely on live affordability state; opt.enabled may be stale after updates
353
+ if (!item.hasAttribute("aria-disabled")) {
354
+ this.hideTowerPalette();
355
+ if (this._paletteSelectCb) this._paletteSelectCb(opt.key);
356
+ }
357
+ });
358
+
359
+ item.appendChild(label);
360
+ item.appendChild(cost);
361
+ list.appendChild(item);
362
+ });
363
+
364
+ this.palette.appendChild(list);
365
+
366
+ // Position palette; nudge to keep on-screen
367
+ const pad = 8;
368
+ const rectW = 200;
369
+ this.palette.style.left =
370
+ Math.min(window.innerWidth - rectW - pad, Math.max(pad, screenX + 10)) +
371
+ "px";
372
+ this.palette.style.top =
373
+ Math.min(window.innerHeight - 160 - pad, Math.max(pad, screenY + 10)) +
374
+ "px";
375
+ this.palette.style.width = rectW + "px";
376
+ this.palette.classList.remove("hidden");
377
+
378
+ // After rendering, ensure initial affordability reflects current money if subscribed
379
+ if (this._gameStateForSubscriptions) {
380
+ this.updateTowerAffordability(this._gameStateForSubscriptions.money);
381
+ }
382
+ }
383
+
384
+ hideTowerPalette() {
385
+ this.palette.classList.add("hidden");
386
+ }
387
+
388
+ /**
389
+ * Optional teardown to prevent leaks: unsubscribe from GameState events.
390
+ */
391
+ destroy() {
392
+ const gs = this._gameStateForSubscriptions;
393
+ if (gs && this._moneyChangedHandler) {
394
+ if (typeof gs.unsubscribeMoneyChanged === "function") {
395
+ gs.unsubscribeMoneyChanged(this._moneyChangedHandler);
396
+ } else if (typeof gs.off === "function") {
397
+ gs.off("moneyChanged", this._moneyChangedHandler);
398
+ }
399
+ }
400
+ this._gameStateForSubscriptions = null;
401
+ this._moneyChangedHandler = null;
402
+
403
+ // Existing global listeners cleanup as a best practice
404
+ window.removeEventListener("mousedown", this._outsideHandler);
405
+ window.removeEventListener("keydown", this._escHandler);
406
+ }
407
+ }
src/utils/utils.js ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as THREE from "three";
2
+ import { PATH_POINTS, GRID_CELL_SIZE } from "../config/gameConfig.js";
3
+
4
+ // Snap value to grid (to nearest grid line)
5
+ export function snapToGrid(value, size = GRID_CELL_SIZE) {
6
+ return Math.round(value / size) * size;
7
+ }
8
+
9
+ // Grid helpers to work with cell centers
10
+ export function worldToCell(x, z, size = GRID_CELL_SIZE) {
11
+ // Map world coords to integer col/row indices
12
+ const col = Math.floor(x / size);
13
+ const row = Math.floor(z / size);
14
+ return { col, row };
15
+ }
16
+
17
+ export function cellToWorldCenter(col, row, size = GRID_CELL_SIZE) {
18
+ // Center of the tile: (col + 0.5, row + 0.5) * size
19
+ return new THREE.Vector3((col + 0.5) * size, 0, (row + 0.5) * size);
20
+ }
21
+
22
+ // Check if point is on road
23
+ export function isOnRoad(p, pathPoints = PATH_POINTS) {
24
+ const halfWidth = 1.6;
25
+ for (let i = 0; i < pathPoints.length - 1; i++) {
26
+ const a = pathPoints[i];
27
+ const b = pathPoints[i + 1];
28
+ if (pointSegmentDistance2D(p, a, b) <= halfWidth) return true;
29
+ }
30
+ return false;
31
+ }
32
+
33
+ // Calculate distance from point to line segment in 2D
34
+ export function pointSegmentDistance2D(p, a, b) {
35
+ const apx = p.x - a.x,
36
+ apz = p.z - a.z;
37
+ const abx = b.x - a.x,
38
+ abz = b.z - a.z;
39
+ const abLenSq = abx * abx + abz * abz;
40
+ let t = 0;
41
+ if (abLenSq > 0) t = (apx * abx + apz * abz) / abLenSq;
42
+ t = Math.max(0, Math.min(1, t));
43
+ const cx = a.x + t * abx,
44
+ cz = a.z + t * abz;
45
+ const dx = p.x - cx,
46
+ dz = p.z - cz;
47
+ return Math.hypot(dx, dz);
48
+ }
49
+
50
+ // Simple particle/hit effect system
51
+ export class EffectSystem {
52
+ constructor(scene) {
53
+ this.scene = scene;
54
+ this.effects = [];
55
+ }
56
+
57
+ spawnHitEffect(pos) {
58
+ const geo = new THREE.SphereGeometry(0.2, 6, 6);
59
+ const mat = new THREE.MeshBasicMaterial({
60
+ color: 0xfff176,
61
+ transparent: true,
62
+ opacity: 0.9,
63
+ });
64
+ const m = new THREE.Mesh(geo, mat);
65
+ m.position.copy(pos);
66
+ this.scene.add(m);
67
+ this.effects.push({ mesh: m, life: 0.25 });
68
+ }
69
+
70
+ update(dt) {
71
+ for (let i = this.effects.length - 1; i >= 0; i--) {
72
+ const e = this.effects[i];
73
+ e.life -= dt;
74
+ e.mesh.scale.addScalar(6 * dt);
75
+ e.mesh.material.opacity = Math.max(0, e.life / 0.25);
76
+ if (e.life <= 0) {
77
+ this.scene.remove(e.mesh);
78
+ this.effects.splice(i, 1);
79
+ }
80
+ }
81
+ }
82
+ }
styles/theme.css ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Military/Tech Tactical Theme Tokens */
2
+ @import url('https://fonts.googleapis.com/css2?family=Rajdhani:wght@500;700&family=Roboto+Mono:wght@400;700&family=Inter:wght@400;600&display=swap');
3
+
4
+ :root {
5
+ /* Palette */
6
+ --bg-0: #0c0f10; /* canvas background */
7
+ --bg-1: #111517; /* base panel */
8
+ --bg-2: #151b1e; /* elevated panel */
9
+ --bg-3: #0b1114; /* deep panel */
10
+ --panel-border: #243039;
11
+ --panel-glow: #0aa39b33;
12
+
13
+ /* Accents */
14
+ --accent: #0aa39b; /* teal accent */
15
+ --accent-600: #08877f;
16
+ --accent-700: #066d67;
17
+ --accent-300: #3bc6bf;
18
+ --accent-200: #69ddd7;
19
+
20
+ /* Status */
21
+ --ok: #12b886;
22
+ --warn: #f59e0b;
23
+ --danger: #ef4444;
24
+
25
+ /* Text */
26
+ --text-1: #e6edf3;
27
+ --text-2: #b6c2cc;
28
+ --text-3: #8a99a7;
29
+
30
+ /* Grid / Texture */
31
+ --grid-line: #1a2228;
32
+ --scanline: #0e1417;
33
+
34
+ /* Shadows */
35
+ --shadow-1: 0 6px 18px rgba(0,0,0,.35);
36
+ --shadow-2: 0 10px 24px rgba(0,0,0,.4);
37
+ --inset-1: inset 0 0 0 1px var(--panel-border);
38
+ --glow-1: 0 0 0 2px var(--panel-glow);
39
+
40
+ /* Radii */
41
+ --r-1: 6px;
42
+ --r-2: 8px;
43
+ --r-3: 12px;
44
+
45
+ /* Spacing scale */
46
+ --s-1: 4px;
47
+ --s-2: 6px;
48
+ --s-3: 8px;
49
+ --s-4: 10px;
50
+ --s-5: 12px;
51
+ --s-6: 16px;
52
+ --s-7: 20px;
53
+
54
+ /* Typography */
55
+ --font-display: "Rajdhani", system-ui, sans-serif;
56
+ --font-mono: "Roboto Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
57
+ --font-body: "Inter", system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
58
+
59
+ --fs-10: 10px;
60
+ --fs-12: 12px;
61
+ --fs-14: 14px;
62
+ --fs-16: 16px;
63
+
64
+ /* Transitions */
65
+ --t-fast: 120ms ease-out;
66
+ --t-med: 180ms ease-out;
67
+ --t-slow: 260ms cubic-bezier(.22,1,.36,1);
68
+ }
69
+
70
+ /* Global baseline */
71
+ html, body {
72
+ height: 100%;
73
+ background: var(--bg-0);
74
+ color: var(--text-1);
75
+ font-family: var(--font-body);
76
+ }
77
+
78
+ .game-grid-bg {
79
+ position: fixed;
80
+ inset: 0;
81
+ pointer-events: none;
82
+ background:
83
+ linear-gradient(var(--scanline) 1px, transparent 1px) 0 0 / 100% 3px,
84
+ linear-gradient(90deg, var(--grid-line) 1px, transparent 1px) 0 0 / 24px 100%,
85
+ linear-gradient(var(--grid-line) 1px, transparent 1px) 0 0 / 100% 24px;
86
+ opacity: .25;
87
+ z-index: 0;
88
+ }
89
+
90
+ /* Utility */
91
+ .u-flex { display: flex; }
92
+ .u-center { display: flex; align-items: center; justify-content: center; }
93
+ .u-gap-2 { gap: var(--s-3); }
94
+ .u-gap-3 { gap: var(--s-4); }
95
+ .u-muted { color: var(--text-3); }
96
+
97
+ .hidden { display: none !important; }
styles/ui.css ADDED
@@ -0,0 +1,254 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Components styled using tokens from styles/theme.css */
2
+
3
+ /* Layout layers */
4
+ .ui-layer {
5
+ position: fixed;
6
+ inset: 0;
7
+ pointer-events: none;
8
+ z-index: 10;
9
+ }
10
+
11
+ .hud {
12
+ pointer-events: none;
13
+ position: fixed;
14
+ top: var(--s-6);
15
+ left: var(--s-6);
16
+ right: var(--s-6);
17
+ display: flex;
18
+ align-items: center;
19
+ justify-content: space-between;
20
+ gap: var(--s-6);
21
+ z-index: 20;
22
+ }
23
+
24
+ /* Panels */
25
+ .panel {
26
+ pointer-events: auto;
27
+ background: linear-gradient(180deg, var(--bg-2), var(--bg-1));
28
+ border-radius: var(--r-2);
29
+ box-shadow: var(--shadow-1), var(--inset-1);
30
+ border: 1px solid var(--panel-border);
31
+ }
32
+
33
+ .panel--compact {
34
+ padding: var(--s-4) var(--s-5);
35
+ }
36
+ .panel--std {
37
+ padding: var(--s-6);
38
+ }
39
+
40
+ .panel-title {
41
+ font-family: var(--font-display);
42
+ font-weight: 700;
43
+ font-size: var(--fs-14);
44
+ letter-spacing: .04em;
45
+ color: var(--text-2);
46
+ text-transform: uppercase;
47
+ margin-bottom: var(--s-4);
48
+ }
49
+
50
+ /* Chips */
51
+ .chips {
52
+ display: flex;
53
+ gap: var(--s-3);
54
+ align-items: center;
55
+ }
56
+
57
+ .chip {
58
+ display: inline-flex;
59
+ align-items: center;
60
+ gap: var(--s-3);
61
+ padding: var(--s-2) var(--s-4);
62
+ border-radius: var(--r-1);
63
+ background: var(--bg-3);
64
+ border: 1px solid var(--panel-border);
65
+ box-shadow: var(--inset-1);
66
+ font-family: var(--font-mono);
67
+ font-size: var(--fs-12);
68
+ color: var(--text-1);
69
+ }
70
+
71
+ .chip__label {
72
+ font-family: var(--font-display);
73
+ font-size: var(--fs-12);
74
+ letter-spacing: .06em;
75
+ color: var(--text-3);
76
+ text-transform: uppercase;
77
+ }
78
+
79
+ /* Speed Controls */
80
+ .speed-controls {
81
+ pointer-events: auto;
82
+ position: fixed;
83
+ top: var(--s-6);
84
+ right: var(--s-6);
85
+ display: flex;
86
+ gap: var(--s-3);
87
+ padding: var(--s-3) var(--s-4);
88
+ border-radius: var(--r-2);
89
+ background: linear-gradient(180deg, var(--bg-2), var(--bg-1));
90
+ border: 1px solid var(--panel-border);
91
+ box-shadow: var(--shadow-1), var(--inset-1);
92
+ z-index: 30;
93
+ }
94
+
95
+ .btn {
96
+ appearance: none;
97
+ border: 1px solid var(--panel-border);
98
+ background: #0f1518;
99
+ color: var(--text-1);
100
+ padding: var(--s-2) var(--s-4);
101
+ border-radius: var(--r-1);
102
+ font-size: var(--fs-12);
103
+ font-family: var(--font-display);
104
+ letter-spacing: .06em;
105
+ text-transform: uppercase;
106
+ cursor: pointer;
107
+ transition: background var(--t-fast), color var(--t-fast), box-shadow var(--t-fast), transform var(--t-fast), border-color var(--t-fast);
108
+ min-width: 44px;
109
+ }
110
+
111
+ .btn:active {
112
+ transform: translateY(1px);
113
+ background: #0e1518;
114
+ }
115
+
116
+ .btn:disabled,
117
+ .btn[disabled] {
118
+ opacity: .5;
119
+ cursor: not-allowed;
120
+ }
121
+
122
+ .btn--primary {
123
+ background: linear-gradient(180deg, var(--accent-300), var(--accent));
124
+ color: #041012;
125
+ border-color: var(--accent-700);
126
+ box-shadow: 0 2px 0 0 rgba(0,0,0,.3) inset;
127
+ }
128
+
129
+ .btn--primary:hover {
130
+ box-shadow: 0 0 0 2px var(--accent-200)55 inset, var(--glow-1);
131
+ }
132
+
133
+ .btn--toggle[aria-pressed="true"] {
134
+ background: var(--accent);
135
+ color: #041012;
136
+ box-shadow: 0 0 0 2px var(--accent-200)55 inset;
137
+ border-color: var(--accent-700);
138
+ }
139
+
140
+ /* Upgrade Panel */
141
+ .upgrade-panel {
142
+ pointer-events: auto;
143
+ position: fixed;
144
+ bottom: var(--s-6);
145
+ left: var(--s-6);
146
+ right: var(--s-6);
147
+ max-width: 560px;
148
+ background: linear-gradient(180deg, var(--bg-2), var(--bg-1));
149
+ border: 1px solid var(--panel-border);
150
+ border-radius: var(--r-3);
151
+ box-shadow: var(--shadow-2), var(--inset-1);
152
+ padding: var(--s-6);
153
+ z-index: 20;
154
+ }
155
+
156
+ .stat-grid {
157
+ display: grid;
158
+ grid-template-columns: auto 1fr;
159
+ gap: var(--s-3) var(--s-6);
160
+ align-items: center;
161
+ margin-bottom: var(--s-6);
162
+ }
163
+
164
+ .stat-label {
165
+ color: var(--text-3);
166
+ font-family: var(--font-display);
167
+ font-size: var(--fs-12);
168
+ text-transform: uppercase;
169
+ letter-spacing: .06em;
170
+ }
171
+
172
+ .stat-value {
173
+ font-family: var(--font-mono);
174
+ font-size: var(--fs-14);
175
+ }
176
+
177
+ /* Tower Palette (floating menu) */
178
+ .palette {
179
+ pointer-events: auto;
180
+ position: fixed;
181
+ background: linear-gradient(180deg, var(--bg-2), var(--bg-1));
182
+ border: 1px solid var(--panel-border);
183
+ border-radius: var(--r-2);
184
+ padding: var(--s-5);
185
+ color: var(--text-1);
186
+ box-shadow: var(--shadow-2), var(--inset-1);
187
+ width: 200px;
188
+ z-index: 1000;
189
+ }
190
+
191
+ .palette-title {
192
+ font-family: var(--font-display);
193
+ font-size: var(--fs-12);
194
+ color: var(--text-3);
195
+ letter-spacing: .06em;
196
+ text-transform: uppercase;
197
+ margin-bottom: var(--s-4);
198
+ }
199
+
200
+ .palette-list {
201
+ display: flex;
202
+ flex-direction: column;
203
+ gap: var(--s-3);
204
+ }
205
+
206
+ .palette-item {
207
+ display: flex;
208
+ align-items: center;
209
+ justify-content: space-between;
210
+ gap: var(--s-3);
211
+ padding: var(--s-3) var(--s-4);
212
+ border-radius: var(--r-1);
213
+ border: 1px solid var(--panel-border);
214
+ background: #122026;
215
+ color: var(--text-1);
216
+ cursor: pointer;
217
+ transition: transform var(--t-fast), box-shadow var(--t-fast), background var(--t-fast), color var(--t-fast);
218
+ }
219
+
220
+ .palette-item:hover {
221
+ transform: translateY(-1px);
222
+ box-shadow: var(--glow-1);
223
+ background: #14262c;
224
+ }
225
+
226
+ .palette-item[aria-disabled="true"] {
227
+ opacity: .5;
228
+ }
229
+
230
+ /* Messages bar */
231
+ .message-bar {
232
+ pointer-events: none;
233
+ position: fixed;
234
+ top: calc(48px + var(--s-6));
235
+ left: 50%;
236
+ transform: translateX(-50%);
237
+ min-width: 320px;
238
+ max-width: 70vw;
239
+ text-align: center;
240
+ background: var(--bg-3);
241
+ border: 1px solid var(--panel-border);
242
+ border-radius: var(--r-2);
243
+ padding: var(--s-3) var(--s-5);
244
+ color: var(--text-2);
245
+ box-shadow: var(--shadow-1), var(--inset-1);
246
+ z-index: 15;
247
+ }
248
+
249
+ /* Focus ring for accessibility */
250
+ :focus-visible {
251
+ outline: none;
252
+ box-shadow: 0 0 0 2px #000 inset, 0 0 0 3px var(--accent-300);
253
+ border-radius: var(--r-1);
254
+ }