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 |
+
}
|