Compare commits

...

No commits in common. "main" and "master" have entirely different histories.
main ... master

5 changed files with 894 additions and 2 deletions

2
Dockerfile Normal file
View File

@ -0,0 +1,2 @@
FROM nginx:alpine
COPY html/ /usr/share/nginx/html/

View File

@ -1,2 +0,0 @@
# tower-defense

12
docker-compose.yml Normal file
View File

@ -0,0 +1,12 @@
services:
web:
image: nginx:alpine
labels:
fibe.gg/repo_url: "https://git-next.fibe.live/viktorvsk/tower-defense"
fibe.gg/expose: "external:80"
fibe.gg/subdomain: "tower-defense"
fibe.gg/production: "false"
fibe.gg/start_command: "nginx -g 'daemon off;'"
environment:
- NGINX_HOST=localhost
- NGINX_PORT=80

440
html/index.html Normal file
View File

@ -0,0 +1,440 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tower Defense</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #1a1a2e;
color: #eee;
font-family: 'Segoe UI', sans-serif;
display: flex;
flex-direction: column;
align-items: center;
min-height: 100vh;
padding: 20px;
}
h1 { margin-bottom: 10px; color: #e94560; }
#ui {
display: flex;
gap: 20px;
margin-bottom: 10px;
font-size: 18px;
}
#ui span { background: #16213e; padding: 8px 16px; border-radius: 8px; }
#canvas {
border: 2px solid #0f3460;
border-radius: 8px;
cursor: crosshair;
}
#tower-select {
display: flex;
gap: 10px;
margin-top: 10px;
}
.tower-btn {
padding: 10px 20px;
border: 2px solid #0f3460;
border-radius: 8px;
background: #16213e;
color: #eee;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.tower-btn:hover { background: #0f3460; }
.tower-btn.selected { border-color: #e94560; background: #e94560; }
#game-over {
display: none;
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: #16213e;
padding: 40px;
border-radius: 16px;
text-align: center;
border: 3px solid #e94560;
}
#game-over h2 { color: #e94560; margin-bottom: 20px; }
#game-over button {
padding: 12px 30px;
font-size: 18px;
background: #e94560;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
}
</style>
</head>
<body>
<h1>Tower Defense</h1>
<div id="ui">
<span>Gold: <b id="gold">100</b></span>
<span>Lives: <b id="lives">20</b></span>
<span>Wave: <b id="wave">1</b></span>
<span>Score: <b id="score">0</b></span>
</div>
<canvas id="canvas" width="800" height="600"></canvas>
<div id="tower-select">
<button class="tower-btn selected" data-type="basic">Basic (50g)</button>
<button class="tower-btn" data-type="sniper">Sniper (100g)</button>
<button class="tower-btn" data-type="rapid">Rapid (75g)</button>
<button class="tower-btn" data-type="slow">Slow (60g)</button>
</div>
<div id="game-over">
<h2>Game Over!</h2>
<p>Final Score: <b id="final-score">0</b></p>
<br>
<button onclick="location.reload()">Play Again</button>
</div>
<script>
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const CELL = 40;
const COLS = canvas.width / CELL;
const ROWS = canvas.height / CELL;
// Game state
let gold = 100;
let lives = 20;
let wave = 1;
let score = 0;
let selectedTower = 'basic';
let gameOver = false;
let enemies = [];
let towers = [];
let projectiles = [];
let particles = [];
let waveTimer = 0;
let enemiesInWave = 0;
let enemiesSpawned = 0;
let spawnTimer = 0;
// Path waypoints (grid coords)
const path = [
{x:0,y:2},{x:5,y:2},{x:5,y:7},{x:10,y:7},{x:10,y:3},{x:15,y:3},{x:15,y:10},{x:19,y:10}
];
// Tower definitions
const TOWER_DEFS = {
basic: { cost: 50, range: 120, damage: 20, fireRate: 1000, color: '#4ecca3', projColor: '#4ecca3', projSpeed: 5 },
sniper: { cost: 100, range: 250, damage: 80, fireRate: 2500, color: '#e94560', projColor: '#e94560', projSpeed: 10 },
rapid: { cost: 75, range: 90, damage: 8, fireRate: 200, color: '#f7b731', projColor: '#f7b731', projSpeed: 6 },
slow: { cost: 60, range: 100, damage: 10, fireRate: 1500, color: '#45aaf2', projColor: '#45aaf2', projSpeed: 4, slow: 0.5 }
};
function getPixelPath() {
return path.map(p => ({x: p.x * CELL + CELL/2, y: p.y * CELL + CELL/2}));
}
function getEnemyPos(progress) {
const pp = getPixelPath();
let dist = 0;
for (let i = 1; i < pp.length; i++) {
const dx = pp[i].x - pp[i-1].x;
const dy = pp[i].y - pp[i-1].y;
const segLen = Math.sqrt(dx*dx + dy*dy);
if (progress <= dist + segLen) {
const t = (progress - dist) / segLen;
return { x: pp[i-1].x + dx * t, y: pp[i-1].y + dy * t };
}
dist += segLen;
}
return { x: pp[pp.length-1].x, y: pp[pp.length-1].y };
}
function totalPathLength() {
const pp = getPixelPath();
let len = 0;
for (let i = 1; i < pp.length; i++) {
const dx = pp[i].x - pp[i-1].x;
const dy = pp[i].y - pp[i-1].y;
len += Math.sqrt(dx*dx + dy*dy);
}
return len;
}
function spawnEnemy() {
const hp = 40 + wave * 25;
const speed = 1.2 + wave * 0.08;
enemies.push({
progress: 0,
hp, maxHp: hp,
speed, baseSpeed: speed,
radius: 12,
slowTimer: 0,
reward: 10 + wave * 2
});
}
function startWave() {
enemiesInWave = 5 + wave * 3;
enemiesSpawned = 0;
spawnTimer = 0;
}
function drawPath() {
ctx.strokeStyle = '#2a2a4a';
ctx.lineWidth = CELL * 0.7;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
const pp = getPixelPath();
ctx.beginPath();
ctx.moveTo(pp[0].x, pp[0].y);
for (let i = 1; i < pp.length; i++) ctx.lineTo(pp[i].x, pp[i].y);
ctx.stroke();
}
function drawGrid() {
ctx.strokeStyle = '#1e1e3a';
ctx.lineWidth = 0.5;
for (let x = 0; x <= canvas.width; x += CELL) {
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, canvas.height); ctx.stroke();
}
for (let y = 0; y <= canvas.height; y += CELL) {
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(canvas.width, y); ctx.stroke();
}
}
function isOnPath(gx, gy) {
for (let i = 0; i < path.length - 1; i++) {
const a = path[i], b = path[i+1];
const minX = Math.min(a.x, b.x), maxX = Math.max(a.x, b.x);
const minY = Math.min(a.y, b.y), maxY = Math.max(a.y, b.y);
if (gx >= minX && gx <= maxX && gy >= minY && gy <= maxY) return true;
}
return false;
}
function drawTowers() {
towers.forEach(t => {
const def = TOWER_DEFS[t.type];
ctx.fillStyle = def.color;
ctx.beginPath();
ctx.arc(t.x, t.y, 15, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#fff';
ctx.beginPath();
ctx.arc(t.x, t.y, 8, 0, Math.PI * 2);
ctx.fill();
});
}
function drawEnemies() {
enemies.forEach(e => {
const pos = getEnemyPos(e.progress);
ctx.fillStyle = e.slowTimer > 0 ? '#45aaf2' : '#ff6b6b';
ctx.beginPath();
ctx.arc(pos.x, pos.y, e.radius, 0, Math.PI * 2);
ctx.fill();
// HP bar
const barW = 24, barH = 4;
ctx.fillStyle = '#333';
ctx.fillRect(pos.x - barW/2, pos.y - e.radius - 10, barW, barH);
ctx.fillStyle = '#4ecca3';
ctx.fillRect(pos.x - barW/2, pos.y - e.radius - 10, barW * (e.hp / e.maxHp), barH);
});
}
function drawProjectiles() {
projectiles.forEach(p => {
ctx.fillStyle = p.color;
ctx.beginPath();
ctx.arc(p.x, p.y, 4, 0, Math.PI * 2);
ctx.fill();
});
}
function drawParticles() {
particles.forEach(p => {
ctx.globalAlpha = p.life;
ctx.fillStyle = p.color;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fill();
});
ctx.globalAlpha = 1;
}
function update(dt) {
if (gameOver) return;
// Spawn enemies
if (enemiesSpawned < enemiesInWave) {
spawnTimer += dt;
if (spawnTimer >= 800) {
spawnEnemy();
enemiesSpawned++;
spawnTimer = 0;
}
} else if (enemies.length === 0) {
waveTimer += dt;
if (waveTimer >= 2000) {
wave++;
waveTimer = 0;
startWave();
updateUI();
}
}
// Update enemies
const pathLen = totalPathLength();
for (let i = enemies.length - 1; i >= 0; i--) {
const e = enemies[i];
e.progress += e.speed * (e.slowTimer > 0 ? 0.5 : 1) * dt / 16;
if (e.slowTimer > 0) e.slowTimer -= dt;
if (e.progress >= pathLen) {
lives--;
enemies.splice(i, 1);
updateUI();
if (lives <= 0) endGame();
}
}
// Towers fire
towers.forEach(t => {
t.cooldown -= dt;
if (t.cooldown <= 0) {
const def = TOWER_DEFS[t.type];
let target = null, minDist = Infinity;
enemies.forEach(e => {
const pos = getEnemyPos(e.progress);
const dist = Math.hypot(pos.x - t.x, pos.y - t.y);
if (dist <= def.range && dist < minDist) {
minDist = dist;
target = e;
}
});
if (target) {
const pos = getEnemyPos(target.progress);
projectiles.push({
x: t.x, y: t.y,
target, tx: pos.x, ty: pos.y,
damage: def.damage, speed: def.projSpeed,
color: def.projColor, slow: def.slow || 0
});
t.cooldown = def.fireRate;
}
}
});
// Update projectiles
for (let i = projectiles.length - 1; i >= 0; i--) {
const p = projectiles[i];
if (p.target && enemies.includes(p.target)) {
const pos = getEnemyPos(p.target.progress);
p.tx = pos.x; p.ty = pos.y;
}
const dx = p.tx - p.x, dy = p.ty - p.y;
const dist = Math.hypot(dx, dy);
if (dist < p.speed * 2) {
if (p.target && enemies.includes(p.target)) {
p.target.hp -= p.damage;
if (p.slow > 0) p.target.slowTimer = 2000;
if (p.target.hp <= 0) {
gold += p.target.reward;
score += p.target.reward * 2;
for (let j = 0; j < 8; j++) {
particles.push({
x: getEnemyPos(p.target.progress).x,
y: getEnemyPos(p.target.progress).y,
vx: (Math.random() - 0.5) * 4,
vy: (Math.random() - 0.5) * 4,
life: 1, size: 3 + Math.random() * 3,
color: '#ff6b6b'
});
}
enemies.splice(enemies.indexOf(p.target), 1);
updateUI();
}
}
projectiles.splice(i, 1);
} else {
p.x += (dx / dist) * p.speed;
p.y += (dy / dist) * p.speed;
}
}
// Update particles
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.x += p.vx; p.y += p.vy;
p.life -= 0.03;
if (p.life <= 0) particles.splice(i, 1);
}
}
function draw() {
ctx.fillStyle = '#1a1a2e';
ctx.fillRect(0, 0, canvas.width, canvas.height);
drawGrid();
drawPath();
drawTowers();
drawEnemies();
drawProjectiles();
drawParticles();
}
function updateUI() {
document.getElementById('gold').textContent = gold;
document.getElementById('lives').textContent = lives;
document.getElementById('wave').textContent = wave;
document.getElementById('score').textContent = score;
}
function endGame() {
gameOver = true;
document.getElementById('final-score').textContent = score;
document.getElementById('game-over').style.display = 'block';
}
// Input
canvas.addEventListener('click', e => {
if (gameOver) return;
const rect = canvas.getBoundingClientRect();
const mx = (e.clientX - rect.left) * (canvas.width / rect.width);
const my = (e.clientY - rect.top) * (canvas.height / rect.height);
const gx = Math.floor(mx / CELL);
const gy = Math.floor(my / CELL);
if (gx < 0 || gx >= COLS || gy < 0 || gy >= ROWS) return;
if (isOnPath(gx, gy)) return;
const def = TOWER_DEFS[selectedTower];
if (gold < def.cost) return;
if (towers.some(t => t.gx === gx && t.gy === gy)) return;
gold -= def.cost;
towers.push({
x: gx * CELL + CELL/2, y: gy * CELL + CELL/2,
gx, gy, type: selectedTower, cooldown: 0
});
updateUI();
});
document.querySelectorAll('.tower-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.tower-btn').forEach(b => b.classList.remove('selected'));
btn.classList.add('selected');
selectedTower = btn.dataset.type;
});
});
// Game loop
let lastTime = 0;
function loop(time) {
const dt = time - lastTime;
lastTime = time;
update(dt);
draw();
requestAnimationFrame(loop);
}
startWave();
updateUI();
requestAnimationFrame(loop);
</script>
</body>
</html>

440
index.html Normal file
View File

@ -0,0 +1,440 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tower Defense</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #1a1a2e;
color: #eee;
font-family: 'Segoe UI', sans-serif;
display: flex;
flex-direction: column;
align-items: center;
min-height: 100vh;
padding: 20px;
}
h1 { margin-bottom: 10px; color: #e94560; }
#ui {
display: flex;
gap: 20px;
margin-bottom: 10px;
font-size: 18px;
}
#ui span { background: #16213e; padding: 8px 16px; border-radius: 8px; }
#canvas {
border: 2px solid #0f3460;
border-radius: 8px;
cursor: crosshair;
}
#tower-select {
display: flex;
gap: 10px;
margin-top: 10px;
}
.tower-btn {
padding: 10px 20px;
border: 2px solid #0f3460;
border-radius: 8px;
background: #16213e;
color: #eee;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.tower-btn:hover { background: #0f3460; }
.tower-btn.selected { border-color: #e94560; background: #e94560; }
#game-over {
display: none;
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: #16213e;
padding: 40px;
border-radius: 16px;
text-align: center;
border: 3px solid #e94560;
}
#game-over h2 { color: #e94560; margin-bottom: 20px; }
#game-over button {
padding: 12px 30px;
font-size: 18px;
background: #e94560;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
}
</style>
</head>
<body>
<h1>Tower Defense</h1>
<div id="ui">
<span>Gold: <b id="gold">100</b></span>
<span>Lives: <b id="lives">20</b></span>
<span>Wave: <b id="wave">1</b></span>
<span>Score: <b id="score">0</b></span>
</div>
<canvas id="canvas" width="800" height="600"></canvas>
<div id="tower-select">
<button class="tower-btn selected" data-type="basic">Basic (50g)</button>
<button class="tower-btn" data-type="sniper">Sniper (100g)</button>
<button class="tower-btn" data-type="rapid">Rapid (75g)</button>
<button class="tower-btn" data-type="slow">Slow (60g)</button>
</div>
<div id="game-over">
<h2>Game Over!</h2>
<p>Final Score: <b id="final-score">0</b></p>
<br>
<button onclick="location.reload()">Play Again</button>
</div>
<script>
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const CELL = 40;
const COLS = canvas.width / CELL;
const ROWS = canvas.height / CELL;
// Game state
let gold = 100;
let lives = 20;
let wave = 1;
let score = 0;
let selectedTower = 'basic';
let gameOver = false;
let enemies = [];
let towers = [];
let projectiles = [];
let particles = [];
let waveTimer = 0;
let enemiesInWave = 0;
let enemiesSpawned = 0;
let spawnTimer = 0;
// Path waypoints (grid coords)
const path = [
{x:0,y:2},{x:5,y:2},{x:5,y:7},{x:10,y:7},{x:10,y:3},{x:15,y:3},{x:15,y:10},{x:19,y:10}
];
// Tower definitions
const TOWER_DEFS = {
basic: { cost: 50, range: 120, damage: 20, fireRate: 1000, color: '#4ecca3', projColor: '#4ecca3', projSpeed: 5 },
sniper: { cost: 100, range: 250, damage: 80, fireRate: 2500, color: '#e94560', projColor: '#e94560', projSpeed: 10 },
rapid: { cost: 75, range: 90, damage: 8, fireRate: 200, color: '#f7b731', projColor: '#f7b731', projSpeed: 6 },
slow: { cost: 60, range: 100, damage: 10, fireRate: 1500, color: '#45aaf2', projColor: '#45aaf2', projSpeed: 4, slow: 0.5 }
};
function getPixelPath() {
return path.map(p => ({x: p.x * CELL + CELL/2, y: p.y * CELL + CELL/2}));
}
function getEnemyPos(progress) {
const pp = getPixelPath();
let dist = 0;
for (let i = 1; i < pp.length; i++) {
const dx = pp[i].x - pp[i-1].x;
const dy = pp[i].y - pp[i-1].y;
const segLen = Math.sqrt(dx*dx + dy*dy);
if (progress <= dist + segLen) {
const t = (progress - dist) / segLen;
return { x: pp[i-1].x + dx * t, y: pp[i-1].y + dy * t };
}
dist += segLen;
}
return { x: pp[pp.length-1].x, y: pp[pp.length-1].y };
}
function totalPathLength() {
const pp = getPixelPath();
let len = 0;
for (let i = 1; i < pp.length; i++) {
const dx = pp[i].x - pp[i-1].x;
const dy = pp[i].y - pp[i-1].y;
len += Math.sqrt(dx*dx + dy*dy);
}
return len;
}
function spawnEnemy() {
const hp = 40 + wave * 25;
const speed = 1.2 + wave * 0.08;
enemies.push({
progress: 0,
hp, maxHp: hp,
speed, baseSpeed: speed,
radius: 12,
slowTimer: 0,
reward: 10 + wave * 2
});
}
function startWave() {
enemiesInWave = 5 + wave * 3;
enemiesSpawned = 0;
spawnTimer = 0;
}
function drawPath() {
ctx.strokeStyle = '#2a2a4a';
ctx.lineWidth = CELL * 0.7;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
const pp = getPixelPath();
ctx.beginPath();
ctx.moveTo(pp[0].x, pp[0].y);
for (let i = 1; i < pp.length; i++) ctx.lineTo(pp[i].x, pp[i].y);
ctx.stroke();
}
function drawGrid() {
ctx.strokeStyle = '#1e1e3a';
ctx.lineWidth = 0.5;
for (let x = 0; x <= canvas.width; x += CELL) {
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, canvas.height); ctx.stroke();
}
for (let y = 0; y <= canvas.height; y += CELL) {
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(canvas.width, y); ctx.stroke();
}
}
function isOnPath(gx, gy) {
for (let i = 0; i < path.length - 1; i++) {
const a = path[i], b = path[i+1];
const minX = Math.min(a.x, b.x), maxX = Math.max(a.x, b.x);
const minY = Math.min(a.y, b.y), maxY = Math.max(a.y, b.y);
if (gx >= minX && gx <= maxX && gy >= minY && gy <= maxY) return true;
}
return false;
}
function drawTowers() {
towers.forEach(t => {
const def = TOWER_DEFS[t.type];
ctx.fillStyle = def.color;
ctx.beginPath();
ctx.arc(t.x, t.y, 15, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#fff';
ctx.beginPath();
ctx.arc(t.x, t.y, 8, 0, Math.PI * 2);
ctx.fill();
});
}
function drawEnemies() {
enemies.forEach(e => {
const pos = getEnemyPos(e.progress);
ctx.fillStyle = e.slowTimer > 0 ? '#45aaf2' : '#ff6b6b';
ctx.beginPath();
ctx.arc(pos.x, pos.y, e.radius, 0, Math.PI * 2);
ctx.fill();
// HP bar
const barW = 24, barH = 4;
ctx.fillStyle = '#333';
ctx.fillRect(pos.x - barW/2, pos.y - e.radius - 10, barW, barH);
ctx.fillStyle = '#4ecca3';
ctx.fillRect(pos.x - barW/2, pos.y - e.radius - 10, barW * (e.hp / e.maxHp), barH);
});
}
function drawProjectiles() {
projectiles.forEach(p => {
ctx.fillStyle = p.color;
ctx.beginPath();
ctx.arc(p.x, p.y, 4, 0, Math.PI * 2);
ctx.fill();
});
}
function drawParticles() {
particles.forEach(p => {
ctx.globalAlpha = p.life;
ctx.fillStyle = p.color;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fill();
});
ctx.globalAlpha = 1;
}
function update(dt) {
if (gameOver) return;
// Spawn enemies
if (enemiesSpawned < enemiesInWave) {
spawnTimer += dt;
if (spawnTimer >= 800) {
spawnEnemy();
enemiesSpawned++;
spawnTimer = 0;
}
} else if (enemies.length === 0) {
waveTimer += dt;
if (waveTimer >= 2000) {
wave++;
waveTimer = 0;
startWave();
updateUI();
}
}
// Update enemies
const pathLen = totalPathLength();
for (let i = enemies.length - 1; i >= 0; i--) {
const e = enemies[i];
e.progress += e.speed * (e.slowTimer > 0 ? 0.5 : 1) * dt / 16;
if (e.slowTimer > 0) e.slowTimer -= dt;
if (e.progress >= pathLen) {
lives--;
enemies.splice(i, 1);
updateUI();
if (lives <= 0) endGame();
}
}
// Towers fire
towers.forEach(t => {
t.cooldown -= dt;
if (t.cooldown <= 0) {
const def = TOWER_DEFS[t.type];
let target = null, minDist = Infinity;
enemies.forEach(e => {
const pos = getEnemyPos(e.progress);
const dist = Math.hypot(pos.x - t.x, pos.y - t.y);
if (dist <= def.range && dist < minDist) {
minDist = dist;
target = e;
}
});
if (target) {
const pos = getEnemyPos(target.progress);
projectiles.push({
x: t.x, y: t.y,
target, tx: pos.x, ty: pos.y,
damage: def.damage, speed: def.projSpeed,
color: def.projColor, slow: def.slow || 0
});
t.cooldown = def.fireRate;
}
}
});
// Update projectiles
for (let i = projectiles.length - 1; i >= 0; i--) {
const p = projectiles[i];
if (p.target && enemies.includes(p.target)) {
const pos = getEnemyPos(p.target.progress);
p.tx = pos.x; p.ty = pos.y;
}
const dx = p.tx - p.x, dy = p.ty - p.y;
const dist = Math.hypot(dx, dy);
if (dist < p.speed * 2) {
if (p.target && enemies.includes(p.target)) {
p.target.hp -= p.damage;
if (p.slow > 0) p.target.slowTimer = 2000;
if (p.target.hp <= 0) {
gold += p.target.reward;
score += p.target.reward * 2;
for (let j = 0; j < 8; j++) {
particles.push({
x: getEnemyPos(p.target.progress).x,
y: getEnemyPos(p.target.progress).y,
vx: (Math.random() - 0.5) * 4,
vy: (Math.random() - 0.5) * 4,
life: 1, size: 3 + Math.random() * 3,
color: '#ff6b6b'
});
}
enemies.splice(enemies.indexOf(p.target), 1);
updateUI();
}
}
projectiles.splice(i, 1);
} else {
p.x += (dx / dist) * p.speed;
p.y += (dy / dist) * p.speed;
}
}
// Update particles
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.x += p.vx; p.y += p.vy;
p.life -= 0.03;
if (p.life <= 0) particles.splice(i, 1);
}
}
function draw() {
ctx.fillStyle = '#1a1a2e';
ctx.fillRect(0, 0, canvas.width, canvas.height);
drawGrid();
drawPath();
drawTowers();
drawEnemies();
drawProjectiles();
drawParticles();
}
function updateUI() {
document.getElementById('gold').textContent = gold;
document.getElementById('lives').textContent = lives;
document.getElementById('wave').textContent = wave;
document.getElementById('score').textContent = score;
}
function endGame() {
gameOver = true;
document.getElementById('final-score').textContent = score;
document.getElementById('game-over').style.display = 'block';
}
// Input
canvas.addEventListener('click', e => {
if (gameOver) return;
const rect = canvas.getBoundingClientRect();
const mx = (e.clientX - rect.left) * (canvas.width / rect.width);
const my = (e.clientY - rect.top) * (canvas.height / rect.height);
const gx = Math.floor(mx / CELL);
const gy = Math.floor(my / CELL);
if (gx < 0 || gx >= COLS || gy < 0 || gy >= ROWS) return;
if (isOnPath(gx, gy)) return;
const def = TOWER_DEFS[selectedTower];
if (gold < def.cost) return;
if (towers.some(t => t.gx === gx && t.gy === gy)) return;
gold -= def.cost;
towers.push({
x: gx * CELL + CELL/2, y: gy * CELL + CELL/2,
gx, gy, type: selectedTower, cooldown: 0
});
updateUI();
});
document.querySelectorAll('.tower-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.tower-btn').forEach(b => b.classList.remove('selected'));
btn.classList.add('selected');
selectedTower = btn.dataset.type;
});
});
// Game loop
let lastTime = 0;
function loop(time) {
const dt = time - lastTime;
lastTime = time;
update(dt);
draw();
requestAnimationFrame(loop);
}
startWave();
updateUI();
requestAnimationFrame(loop);
</script>
</body>
</html>