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