Initial tower defense game
This commit is contained in:
commit
cb679d9f5f
9
docker-compose.yml
Normal file
9
docker-compose.yml
Normal file
@ -0,0 +1,9 @@
|
||||
services:
|
||||
web:
|
||||
image: nginx:alpine
|
||||
labels:
|
||||
fibe.gg/exposure.enabled: "true"
|
||||
fibe.gg/exposure.port: "80"
|
||||
fibe.gg/production: "false"
|
||||
volumes:
|
||||
- ./html:/usr/share/nginx/html:ro
|
||||
440
html/index.html
Normal file
440
html/index.html
Normal 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
440
index.html
Normal 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>
|
||||
Loading…
x
Reference in New Issue
Block a user