tower-defense / src /main.js
victor's picture
victor HF Staff
Refactor tower system and UI improvements
9e6ef9c
import * as THREE from "three";
import { SceneSetup } from "./scene/SceneSetup.js";
import { PathBuilder } from "./scene/PathBuilder.js";
import { GameState } from "./game/GameState.js";
import { UIManager } from "./ui/UIManager.js";
import { Enemy } from "./entities/Enemy.js";
import { Tower } from "./entities/Tower.js";
import {
TOWER_TYPES,
PATH_POINTS,
PROJECTILE_SPEED,
GRID_CELL_SIZE,
} from "./config/gameConfig.js";
import {
snapToGrid,
isOnRoad,
EffectSystem,
worldToCell,
cellToWorldCenter,
} from "./utils/utils.js";
// Initialize game components
const sceneSetup = new SceneSetup();
const pathBuilder = new PathBuilder(sceneSetup.scene);
const gameState = new GameState();
const uiManager = new UIManager();
const effectSystem = new EffectSystem(sceneSetup.scene);
// Make UIManager globally accessible for tower sound system
window.UIManager = uiManager;
// Build the path
pathBuilder.buildPath();
// Initialize UI
uiManager.setWavesTotal(gameState.totalWaves);
uiManager.updateHUD(gameState);
uiManager.setMessage(
"Click on the ground to place a tower. Press G to toggle grid."
);
// Initialize speed control UI
if (typeof uiManager.initSpeedControls === "function") {
uiManager.initSpeedControls(gameState.getGameSpeed());
}
if (typeof uiManager.onSpeedChange === "function") {
uiManager.onSpeedChange((speed) => {
if (typeof gameState.setGameSpeed === "function") {
gameState.setGameSpeed(speed);
} else {
gameState.gameSpeed = speed === 2 ? 2 : 1;
}
if (typeof uiManager.updateSpeedControls === "function") {
uiManager.updateSpeedControls(
gameState.getGameSpeed ? gameState.getGameSpeed() : gameState.gameSpeed
);
}
});
}
/**
* Raycaster and pointer state
*/
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
// Track hovered tower for outline toggle
let hoveredTower = null;
// Drag/rotation suppression to avoid triggering click after a drag
let isPointerDown = false;
let didDrag = false;
let downPos = { x: 0, y: 0 };
// pixels moved to consider it a drag (tuned to ignore minor jitter)
const DRAG_SUPPRESS_PX = 8;
// Hover highlight overlay (cell preview)
const hoverMaterial = new THREE.MeshBasicMaterial({
color: 0x3a97ff,
transparent: true,
opacity: 0.25,
depthWrite: false,
});
const hoverGeo = new THREE.PlaneGeometry(GRID_CELL_SIZE, GRID_CELL_SIZE);
const hoverMesh = new THREE.Mesh(hoverGeo, hoverMaterial);
hoverMesh.rotation.x = -Math.PI / 2;
hoverMesh.visible = false;
sceneSetup.scene.add(hoverMesh);
// Track last hovered center to reuse on click
let lastHoveredCenter = null;
function updateHover(e) {
// Track drag distance while pointer is down to suppress click-after-drag
if (isPointerDown) {
const dx = e.clientX - downPos.x;
const dy = e.clientY - downPos.y;
if (!didDrag && dx * dx + dy * dy >= DRAG_SUPPRESS_PX * DRAG_SUPPRESS_PX) {
didDrag = true;
}
}
if (!gameState.isGameActive()) {
hoverMesh.visible = false;
// clear tower hover when game inactive
if (hoveredTower) {
hoveredTower.setHovered(false);
hoveredTower = null;
}
return;
}
mouse.x = (e.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(e.clientY / window.innerHeight) * 2 + 1;
raycaster.setFromCamera(mouse, sceneSetup.camera);
// 1) Tower hover detection via raycast
const towerMeshes = gameState.towers.map((t) => t.mesh);
const tHits = towerMeshes.length
? raycaster.intersectObjects(towerMeshes, true)
: [];
if (tHits.length > 0) {
const hitObj = tHits[0].object;
const owner = gameState.towers.find(
(t) =>
hitObj === t.mesh ||
t.mesh.children.includes(hitObj) ||
t.head === hitObj ||
t.ring === hitObj ||
t.mesh.children.some((c) => c === hitObj)
);
if (owner) {
if (hoveredTower && hoveredTower !== owner) {
hoveredTower.setHovered(false);
}
hoveredTower = owner;
hoveredTower.setHovered(true);
}
} else {
if (hoveredTower) {
hoveredTower.setHovered(false);
hoveredTower = null;
}
}
// 2) Ground hover preview (existing behavior)
const intersects = raycaster.intersectObjects([sceneSetup.ground], false);
if (intersects.length === 0) {
hoverMesh.visible = false;
lastHoveredCenter = null;
return;
}
const p = intersects[0].point.clone();
p.y = 0;
// Convert to cell center
const { col, row } = worldToCell(p.x, p.z, GRID_CELL_SIZE);
const center = cellToWorldCenter(col, row, GRID_CELL_SIZE);
// Determine validity using existing constraints
const valid = canPlaceTowerAt(center);
hoverMesh.position.set(center.x, 0.01, center.z);
hoverMesh.material.color.setHex(valid ? 0x3a97ff : 0xff5555);
hoverMesh.visible = true;
lastHoveredCenter = center;
}
function canPlaceTowerAt(pos) {
if (isOnRoad(pos)) {
uiManager.setMessage("Can't place on the road!");
return false;
}
// Allow edge-adjacent placement: threshold based on grid size
const minSeparation = 0.9 * GRID_CELL_SIZE;
for (const t of gameState.towers) {
if (t.position.distanceTo(pos) < minSeparation) {
uiManager.setMessage("Too close to another tower.");
return false;
}
}
return true;
}
// Game functions
function resetGame() {
// Clean up entities
gameState.enemies.forEach((e) => e.destroy());
gameState.towers.forEach((t) => t.destroy());
gameState.projectiles.forEach((p) => p.destroy());
// Reset game state
gameState.reset();
// Ensure speed is reset to x1 at the start of a new game
if (typeof gameState.setGameSpeed === "function") {
gameState.setGameSpeed(1);
} else {
gameState.gameSpeed = 1;
}
if (typeof uiManager.updateSpeedControls === "function") {
uiManager.updateSpeedControls(
gameState.getGameSpeed ? gameState.getGameSpeed() : gameState.gameSpeed
);
}
// Update UI
uiManager.setMessage(
"Click on the ground to place a tower. Press G to toggle grid."
);
uiManager.updateHUD(gameState);
}
function spawnEnemy(wave) {
const enemy = new Enemy(
wave.hp,
wave.speed,
wave.reward,
PATH_POINTS,
sceneSetup.scene
);
gameState.addEnemy(enemy);
}
function setSelectedTower(tower) {
// Clear old selection
if (gameState.selectedTower) {
gameState.selectedTower.setSelected(false);
}
gameState.setSelectedTower(tower);
if (tower) {
tower.setSelected(true);
uiManager.showUpgradePanel(tower, gameState.money);
} else {
uiManager.hideUpgradePanel();
}
}
// Event handlers
function onClick(e) {
if (!gameState.isGameActive()) return;
// Ignore clicks on UI elements
if (e.target.tagName !== 'CANVAS') {
return;
}
// If a drag/rotate/pan occurred, suppress the click action entirely
if (didDrag) {
didDrag = false; // reset for next interaction
// Also clear any transient hover
if (hoveredTower) {
hoveredTower.setHovered(false);
hoveredTower = null;
}
return;
}
mouse.x = (e.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(e.clientY / window.innerHeight) * 2 + 1;
raycaster.setFromCamera(mouse, sceneSetup.camera);
// First, try to select a tower
const towerMeshes = gameState.towers.map((t) => t.mesh);
const tHits = raycaster.intersectObjects(towerMeshes, true);
if (tHits.length > 0) {
// Find owning tower
const hit = tHits[0].object;
const owner = gameState.towers.find(
(t) =>
hit === t.mesh ||
t.mesh.children.includes(hit) ||
t.head === hit ||
t.ring === hit ||
t.mesh.children.some((c) => c === hit)
);
if (owner) {
setSelectedTower(owner);
return;
}
}
// Otherwise, handle ground placement and deselection
const intersects = raycaster.intersectObjects([sceneSetup.ground], false);
if (intersects.length > 0) {
const p = intersects[0].point.clone();
p.y = 0;
// Deselect if clicking ground without Shift
if (!e.shiftKey) {
setSelectedTower(null);
}
// Compute exact cell center instead of intersection
const { col, row } = worldToCell(p.x, p.z, GRID_CELL_SIZE);
const pCenter = cellToWorldCenter(col, row, GRID_CELL_SIZE);
// Place constraints based on center
if (!canPlaceTowerAt(pCenter)) {
return;
}
// Build palette options based on affordability
const opts = [
{
key: "basic",
name: TOWER_TYPES.basic.name,
cost: TOWER_TYPES.basic.cost,
enabled: gameState.canAfford(TOWER_TYPES.basic.cost),
desc: "Balanced tower",
color: "#3a97ff",
},
{
key: "slow",
name: TOWER_TYPES.slow.name,
cost: TOWER_TYPES.slow.cost,
enabled: gameState.canAfford(TOWER_TYPES.slow.cost),
desc: "On-hit slow for 2.5s",
color: "#2fa8ff",
},
{
key: "sniper",
name: TOWER_TYPES.sniper.name,
cost: TOWER_TYPES.sniper.cost,
enabled: gameState.canAfford(TOWER_TYPES.sniper.cost),
desc: "Long range, slow fire, high damage; aims before firing",
color: "#ff3b30",
},
// New Electric tower option
...(TOWER_TYPES.electric
? [
{
key: "electric",
name: TOWER_TYPES.electric.name,
cost: TOWER_TYPES.electric.cost,
enabled: gameState.canAfford(TOWER_TYPES.electric.cost),
desc: "Electric arcs hit up to 3 enemies",
color: "#9ad6ff",
},
]
: []),
];
// Show palette near click
uiManager.showTowerPalette(e.clientX, e.clientY, opts);
// One-time handlers
const handleSelect = (key) => {
const def = TOWER_TYPES[key];
if (!def) return;
// Re-validate position and funds at selection time
if (!canPlaceTowerAt(pCenter)) return;
if (!gameState.canAfford(def.cost)) {
uiManager.setMessage("Not enough money!");
return;
}
gameState.spendMoney(def.cost);
uiManager.updateHUD(gameState);
const tower = new Tower(pCenter, def, sceneSetup.scene);
gameState.addTower(tower);
uiManager.setMessage(`${def.name} placed!`);
};
const handleCancel = () => {
// No-op, message can be preserved
};
uiManager.onPaletteSelect((key) => handleSelect(key));
uiManager.onPaletteCancel(() => handleCancel());
} else {
setSelectedTower(null);
}
// clear any hover state after processing click (prevents stuck outline)
if (hoveredTower) {
hoveredTower.setHovered(false);
hoveredTower = null;
}
}
/**
* Pointer handlers to detect drags (used to suppress click after rotate/pan)
*/
function onMouseDown(e) {
// Only consider primary or secondary buttons; ignore other cases
isPointerDown = true;
didDrag = false;
downPos.x = e.clientX;
downPos.y = e.clientY;
}
function onMouseUp() {
// End drag tracking; let click handler run, which will check didDrag
isPointerDown = false;
}
// Setup event listeners
window.addEventListener("click", onClick);
window.addEventListener("mousemove", updateHover);
window.addEventListener("mousedown", onMouseDown);
window.addEventListener("mouseup", onMouseUp);
// Arrow key state tracking for smooth camera movement
const keyState = {
ArrowUp: false,
ArrowDown: false,
ArrowLeft: false,
ArrowRight: false,
};
window.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
setSelectedTower(null);
}
if (e.key === "g" || e.key === "G") {
sceneSetup.grid.visible = !sceneSetup.grid.visible;
uiManager.setMessage(sceneSetup.grid.visible ? "Grid on" : "Grid off");
}
if (e.key in keyState) {
keyState[e.key] = true;
}
});
window.addEventListener("keyup", (e) => {
if (e.key in keyState) {
keyState[e.key] = false;
}
});
uiManager.onRestartClick(() => resetGame());
uiManager.onUpgradeClick(() => {
const tower = gameState.selectedTower;
if (!tower) return;
if (!tower.canUpgrade) {
uiManager.setMessage("Tower is at max level.");
uiManager.showUpgradePanel(tower, gameState.money);
return;
}
if (!gameState.canAfford(tower.nextUpgradeCost)) {
uiManager.setMessage("Not enough money to upgrade.");
uiManager.showUpgradePanel(tower, gameState.money);
return;
}
gameState.spendMoney(tower.nextUpgradeCost);
const ok = tower.upgrade();
if (ok) {
uiManager.updateHUD(gameState);
uiManager.setMessage("Tower upgraded.");
uiManager.showUpgradePanel(tower, gameState.money);
}
});
uiManager.onSellClick(() => {
const tower = gameState.selectedTower;
if (!tower) return;
const refund = tower.getSellValue();
gameState.addMoney(refund);
uiManager.updateHUD(gameState);
tower.destroy();
gameState.removeTower(tower);
uiManager.setMessage(`Tower sold for ${refund}.`);
setSelectedTower(null);
});
// Wave spawning
function updateSpawning(dt) {
if (!gameState.isGameActive()) return;
const wave = gameState.getCurrentWave();
if (!wave) return;
// Accumulate scaled time and spawn at intervals
if (gameState.spawnedThisWave < wave.count) {
gameState.spawnAccum += dt;
while (
gameState.spawnedThisWave < wave.count &&
gameState.spawnAccum >= wave.spawnInterval
) {
spawnEnemy(wave);
gameState.spawnedThisWave++;
gameState.spawnAccum -= wave.spawnInterval;
}
} else {
// Wait until all enemies are cleared to progress
if (gameState.enemies.length === 0) {
gameState.nextWave();
uiManager.updateHUD(gameState);
// Infinite waves: always start the next wave
if (gameState.startWave()) {
uiManager.setMessage(`Wave ${gameState.waveIndex + 1} started!`);
}
}
}
}
// Main game loop
let lastTime = performance.now() / 1000;
function animate() {
requestAnimationFrame(animate);
const now = performance.now() / 1000;
const dtRaw = Math.min(0.05, now - lastTime);
lastTime = now;
// Scaled gameplay dt based on GameState speed
const speed =
typeof gameState.getGameSpeed === "function"
? gameState.getGameSpeed()
: gameState.gameSpeed || 1;
const dt = dtRaw * speed;
// Camera movement via arrow keys should remain unscaled for consistent navigation
const moveDir = { x: 0, z: 0 };
if (keyState.ArrowUp) moveDir.z -= 1;
if (keyState.ArrowDown) moveDir.z += 1;
if (keyState.ArrowLeft) moveDir.x -= 1;
if (keyState.ArrowRight) moveDir.x += 1;
if (moveDir.x !== 0 || moveDir.z !== 0) {
sceneSetup.moveCamera(moveDir, dtRaw);
}
if (gameState.isGameActive()) {
// Spawning must use scaled dt to respect speed
updateSpawning(dt);
// Update enemies
for (let i = gameState.enemies.length - 1; i >= 0; i--) {
const enemy = gameState.enemies[i];
const status = enemy.update(dt);
if (enemy.isDead()) {
gameState.addMoney(enemy.reward);
uiManager.updateHUD(gameState);
enemy.destroy();
gameState.removeEnemy(enemy);
continue;
}
if (status === "end") {
gameState.takeDamage(1);
uiManager.updateHUD(gameState);
enemy.destroy();
gameState.removeEnemy(enemy);
if (gameState.gameOver) {
uiManager.setMessage("Game Over! Enemies broke through.");
}
}
}
// Update towers
for (const tower of gameState.towers) {
tower.tryFire(
dt,
gameState.enemies,
gameState.projectiles,
PROJECTILE_SPEED
);
}
// Keep upgrade panel in sync if selected
if (gameState.selectedTower) {
uiManager.showUpgradePanel(gameState.selectedTower, gameState.money);
}
// Update projectiles
for (let i = gameState.projectiles.length - 1; i >= 0; i--) {
const projectile = gameState.projectiles[i];
const status = projectile.update(dt, (pos) =>
effectSystem.spawnHitEffect(pos)
);
if (status !== "ok") {
projectile.destroy();
gameState.removeProjectile(projectile);
}
}
effectSystem.update(dt);
}
sceneSetup.render();
}
// Start the game
setTimeout(() => {
if (gameState.startWave()) {
uiManager.setMessage(`Wave 1 started!`);
}
}, 1200);
animate();