441 lines
11 KiB
HTML
441 lines
11 KiB
HTML
<!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>
|