Spaces:
Running
Running
Commit
·
b29710c
0
Parent(s):
Initial commit
Browse files- .DS_Store +0 -0
- index.html +53 -0
- src/config/gameConfig.js +170 -0
- src/entities/Enemy.js +157 -0
- src/entities/Projectile.js +72 -0
- src/entities/Tower.js +1140 -0
- src/game/GameState.js +231 -0
- src/main.js +592 -0
- src/scene/PathBuilder.js +67 -0
- src/scene/SceneSetup.js +151 -0
- src/ui/UIManager.js +407 -0
- src/utils/utils.js +82 -0
- styles/theme.css +97 -0
- styles/ui.css +254 -0
.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 |
+
}
|