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();