Spaces:
Running
Running
| 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; | |
| // Add debug methods to window for console access | |
| window.debug = { | |
| addMoney: (amount) => { | |
| gameState.addMoney(amount); | |
| uiManager.updateHUD(gameState); | |
| console.log(`Added ${amount} money. Current: ${gameState.money}`); | |
| }, | |
| setMoney: (amount) => { | |
| gameState.setMoney(amount); | |
| uiManager.updateHUD(gameState); | |
| console.log(`Set money to ${amount}`); | |
| }, | |
| getMoney: () => { | |
| console.log(`Current money: ${gameState.money}`); | |
| return gameState.money; | |
| }, | |
| setLives: (amount) => { | |
| gameState.lives = amount; | |
| uiManager.updateHUD(gameState); | |
| console.log(`Set lives to ${amount}`); | |
| }, | |
| getLives: () => { | |
| console.log(`Current lives: ${gameState.lives}`); | |
| return gameState.lives; | |
| }, | |
| getGameState: () => gameState, | |
| skipToWave: (waveNum) => { | |
| gameState.waveIndex = waveNum - 1; | |
| uiManager.updateHUD(gameState); | |
| console.log(`Skipped to wave ${waveNum}`); | |
| } | |
| }; | |
| // 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(); | |