Tower Defense — Milestone 2
The Path & Walking Enemies
Milestone 2 — Path & Enemies
# TOWER DEFENSE — MILESTONE 2: The Path & Walking Enemies
# =============================================================================
# TARGET (show me when this works):
# A winding blue path runs from the green ENTRY to the red BASE in the middle.
# Yellow enemies spawn every 2 seconds and walk the whole path.
# When an enemy touches the BASE it disappears and you lose a life.
# At 0 lives the game stops spawning and prints GAME OVER.
#
# NEW IN THIS FILE (compared to Milestone 1):
# - The BASE moved to the MIDDLE of the map.
# WHY: later, enemies will attack from all four sides at once.
# - PATH: a list of "waypoints" (the corners the enemies turn at)
# - path_cells(): turns those corners into every cell along the route
# - class Enemy: an object that knows how to move itself and draw itself
# - A HUD bar at the bottom of the window (the window got 60px taller)
#
# THE ONE IDEA TO UNDERSTAND — WAYPOINT FOLLOWING:
# The enemy always aims at the NEXT corner in the list.
# Each frame it takes one small step toward that corner.
# When it arrives, it moves on to the corner after that.
# To step toward a target we use:
# dx, dy = how far away the target is
# dist = math.hypot(dx, dy) <- Pythagorean theorem
# dx/dist = a "unit vector": direction only, length exactly 1
# then multiply by speed to take a step of the right size
#
# NEXT (Milestone 3): towers that shoot the enemies.
# =============================================================================
import pygame
import math
pygame.init()
COLS, ROWS = 20, 15
CELL = 40
HUD_H = 60
WIDTH = COLS * CELL
HEIGHT = ROWS * CELL + HUD_H
screen = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption("Tower Defense — Milestone 2")
clock = pygame.time.Clock()
font = pygame.font.SysFont("Courier", 18, bold=True)
big_font = pygame.font.SysFont("Courier", 40, bold=True)
# Colors
BG_COLOR = (20, 28, 40)
GRID_COLOR = (30, 40, 55)
OPEN_COLOR = (35, 50, 70)
PATH_COLOR = (52, 84, 126)
ENTRY_COLOR = (50, 180, 80)
BASE_COLOR = (200, 60, 60)
ENEMY_COLOR = (220, 180, 30)
HUD_COLOR = (185, 220, 255)
# Settings
ENEMY_SPEED = 1.5 # pixels per frame
SPAWN_MS = 2000 # a new enemy every 2 seconds
START_LIVES = 25
# Grid Setup — the BASE is now in the CENTER of the map
BASE = (10, 7)
# PATH: just the corners. The enemy walks in straight lines between them.
PATH = [(0, 5), (6, 5), (6, 11), (14, 11), (14, 3), (10, 3), BASE]
grid = [['open'] * ROWS for _ in range(COLS)]
def path_cells(waypoints):
"""Turn a list of corners into EVERY grid cell along the route."""
cells = []
for i in range(len(waypoints) - 1):
col0, row0 = waypoints[i]
col1, row1 = waypoints[i + 1]
# Which direction do we step? -1, 0 or +1 on each axis.
step_col = 0 if col0 == col1 else (1 if col1 > col0 else -1)
step_row = 0 if row0 == row1 else (1 if row1 > row0 else -1)
col, row = col0, row0
while (col, row) != (col1, row1):
cells.append((col, row))
col += step_col
row += step_row
cells.append(waypoints[-1])
return cells
# Write the path into the grid so draw_grid() can color it
for cell in path_cells(PATH):
grid[cell[0]][cell[1]] = 'path'
grid[PATH[0][0]][PATH[0][1]] = 'entry'
grid[BASE[0]][BASE[1]] = 'base'
class Enemy:
RADIUS = 11
def __init__(self, waypoints, speed):
self.waypoints = waypoints
self.index = 0 # which corner are we walking toward
self.speed = speed
col, row = waypoints[0]
self.x = float(col * CELL + CELL / 2)
self.y = float(row * CELL + CELL / 2)
self.alive = True
self.reached_base = False
def update(self):
# Have we run out of corners? Then we are standing on the base.
if self.index >= len(self.waypoints) - 1:
self.alive = False
self.reached_base = True
return
col, row = self.waypoints[self.index + 1]
target_x = col * CELL + CELL / 2
target_y = row * CELL + CELL / 2
dx = target_x - self.x
dy = target_y - self.y
dist = math.hypot(dx, dy)
if dist < self.speed:
# Close enough — snap onto the corner and aim at the next one
self.x = target_x
self.y = target_y
self.index += 1
else:
self.x += dx / dist * self.speed
self.y += dy / dist * self.speed
def draw(self, surface):
center = (int(self.x), int(self.y))
pygame.draw.circle(surface, ENEMY_COLOR, center, self.RADIUS)
pygame.draw.circle(surface, (0, 0, 0), center, self.RADIUS, 2)
def draw_grid():
for row in range(ROWS):
for col in range(COLS):
x = col * CELL
y = row * CELL
cell_type = grid[col][row]
if cell_type == 'base':
color = BASE_COLOR
elif cell_type == 'entry':
color = ENTRY_COLOR
elif cell_type == 'path':
color = PATH_COLOR
else:
color = OPEN_COLOR
pygame.draw.rect(screen, color, (x, y, CELL, CELL))
pygame.draw.rect(screen, GRID_COLOR, (x, y, CELL, CELL), 1)
base_x = BASE[0] * CELL + CELL // 2
base_y = BASE[1] * CELL + CELL // 2
label = font.render("BASE", True, (255, 200, 200))
screen.blit(label, label.get_rect(center=(base_x, base_y)))
def draw_hud(lives):
top = ROWS * CELL
pygame.draw.rect(screen, (12, 16, 26), (0, top, WIDTH, HUD_H))
text = font.render(f"LIVES {lives}", True, HUD_COLOR)
screen.blit(text, (10, top + 12))
hint = font.render("enemies that reach the BASE cost a life", True, (110, 130, 155))
screen.blit(hint, (10, top + 34))
# Game state
enemies = []
lives = START_LIVES
SPAWN_EVENT = pygame.USEREVENT + 1
pygame.time.set_timer(SPAWN_EVENT, SPAWN_MS)
running = True
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
if event.type == SPAWN_EVENT and lives > 0:
enemies.append(Enemy(PATH, ENEMY_SPEED))
# Update every enemy
for enemy in enemies[:]:
enemy.update()
if not enemy.alive:
enemies.remove(enemy)
if enemy.reached_base:
lives -= 1
if lives < 0:
lives = 0
# Draw
screen.fill(BG_COLOR)
draw_grid()
for enemy in enemies:
enemy.draw(screen)
draw_hud(lives)
if lives <= 0:
shade = pygame.Surface((WIDTH, ROWS * CELL), pygame.SRCALPHA)
shade.fill((0, 0, 0, 170))
screen.blit(shade, (0, 0))
msg = big_font.render("GAME OVER", True, (225, 70, 70))
screen.blit(msg, msg.get_rect(center=(WIDTH // 2, ROWS * CELL // 2)))
pygame.display.flip()
clock.tick(60)
pygame.quit()