Tower Defense — Milestone 6

Four Lanes & The Finished Game

# TOWER DEFENSE — MILESTONE 6: Four Lanes & The Finished Game
# =============================================================================
# TARGET (show me when this works):
#   You start defending ONE lane. At 20 kills a second lane opens, at 45 a
#   third, at 75 a fourth — each announced with an orange warning across the
#   top. Lanes that have not opened yet are drawn dim so you can plan ahead.
#   P pauses. R restarts. Game over shows your final score.
#
# NEW IN THIS FILE (compared to Milestone 5):
#   - PATHS: a dictionary of four routes instead of one list
#   - One spawn timer PER lane (four separate pygame timers)
#   - UNLOCKS: lanes that switch on at a kill count, with a flash message
#   - reset_game(): restart without closing the window
#   - Pause
#
# THE ONE IDEA TO UNDERSTAND — A DICTIONARY OF PATHS:
#   In Milestone 5 there was one PATH, so one list was enough.
#   Now every lane needs a name, so we use a dictionary:
#       PATHS = {'left': [...], 'right': [...], 'top': [...], 'bottom': [...]}
#   Everything that used to be a single value becomes "one per lane":
#   a timer per lane, an entry cell per lane, an active/inactive flag per lane.
#   That pattern — going from one thing to a dictionary of things — shows up
#   in almost every program that grows.
#
# WHY THE GAME ALWAYS ENDS EVENTUALLY:
#   Four lanes at once produce more enemies than any tower layout can cover.
#   That is deliberate. This is a HIGH SCORE game, not one you can finish.
#   These numbers were checked by simulating thousands of runs: a careful
#   player reaches roughly 100 kills before the base falls.
#
# EXTENSION challenges (pick one and show me):
#   - A SLOW tower that halves an enemy's speed instead of damaging it
#   - Right-click a tower to sell it back for half price
#   - Every 10th enemy is a BOSS: triple health, drawn bigger, worth 5x coins
#   - Save the best score to a file using the json module
# =============================================================================

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")
clock = pygame.time.Clock()
font = pygame.font.SysFont("Courier", 18, bold=True)
small_font = pygame.font.SysFont("Courier", 15, bold=True)
med_font = pygame.font.SysFont("Courier", 22, 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_ON = (52, 84, 126)      # a lane that is active
PATH_OFF = (40, 52, 68)      # a lane that has not opened yet
ENTRY_COLOR = (50, 180, 80)
BASE_COLOR = (200, 60, 60)
TOWER_COLOR = (58, 140, 70)
TOWER_EDGE = (95, 210, 110)
BULLET_COLOR = (255, 240, 120)
HUD_COLOR = (185, 220, 255)
COIN_COLOR = (255, 205, 60)
WARN_COLOR = (255, 150, 40)
OK_RING = (80, 220, 110)
BAD_RING = (220, 70, 70)
HP_BG = (90, 25, 25)
HP_FG = (70, 205, 90)

# Settings
TOWER_RANGE = 2.5 * CELL
TOWER_RELOAD = 60
BULLET_SPEED = 5
START_LIVES = 25

# Economy
START_COINS = 50
TOWER_COST = 18
COST_STEP = 6
COINS_PER_KILL = 6

# Difficulty
HP_BASE = 1
HP_EVERY = 18
HP_CAP = 8
SPEED_BASE = 1.5
SPEED_STEP = 0.15
SPEED_MAX = 3.5
SPAWN_BASE = 2200
SPAWN_STEP = 100
SPAWN_MIN = 700

BASE = (10, 7)

# Four routes, all ending at the BASE in the middle
PATHS = {
    'left':   [(0, 5),   (6, 5),  (6, 11), (14, 11), (14, 3), (10, 3),  BASE],
    'right':  [(19, 9),  (13, 9), (13, 3), (5, 3),   (5, 11), (10, 11), BASE],
    'top':    [(3, 0),   (3, 7),  (7, 7),  (7, 13),  (13, 13),(13, 7),  BASE],
    'bottom': [(16, 14), (16, 6), (12, 6), (12, 1),  (6, 1),  (6, 7),   BASE],
}

# Kill count that opens each new lane
UNLOCKS = [(20, 'right'), (45, 'top'), (75, 'bottom')]

# Each lane gets its own spawn timer event
SPAWN_EVENTS = {
    'left':   pygame.USEREVENT + 1,
    'right':  pygame.USEREVENT + 2,
    'top':    pygame.USEREVENT + 3,
    'bottom': pygame.USEREVENT + 4,
}


def path_cells(waypoints):
    cells = []
    for i in range(len(waypoints) - 1):
        col0, row0 = waypoints[i]
        col1, row1 = waypoints[i + 1]
        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


# Work out which cells belong to which lane, once, at startup
LANE_CELLS = {name: set(path_cells(wp)) for name, wp in PATHS.items()}
ENTRY_CELLS = {wp[0] for wp in PATHS.values()}

ALL_PATH_CELLS = set()
for cells in LANE_CELLS.values():
    ALL_PATH_CELLS |= cells

grid = [['open'] * ROWS for _ in range(COLS)]


class Enemy:
    RADIUS = 11

    def __init__(self, waypoints, speed, hp):
        self.waypoints = waypoints
        self.index = 0
        self.speed = speed
        self.hp = hp
        self.max_hp = hp
        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
        self.targeted = False

    def update(self):
        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:
            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 hit(self):
        self.hp -= 1
        if self.hp <= 0:
            self.alive = False

    def draw(self, surface):
        center_x, center_y = int(self.x), int(self.y)
        ratio = self.hp / self.max_hp
        color = (int(210 + 30 * (1 - ratio)), int(180 * ratio + 40), 40)
        pygame.draw.circle(surface, color, (center_x, center_y), self.RADIUS)
        pygame.draw.circle(surface, (0, 0, 0), (center_x, center_y), self.RADIUS, 2)
        if self.max_hp > 1:
            bar_w = self.RADIUS * 2
            bar_x = center_x - self.RADIUS
            bar_y = center_y - self.RADIUS - 7
            pygame.draw.rect(surface, HP_BG, (bar_x, bar_y, bar_w, 4))
            pygame.draw.rect(surface, HP_FG, (bar_x, bar_y, int(bar_w * ratio), 4))


class Tower:
    def __init__(self, col, row):
        self.col = col
        self.row = row
        self.x = col * CELL + CELL / 2
        self.y = row * CELL + CELL / 2
        self.cooldown = 0

    def update(self, enemies, bullets):
        if self.cooldown > 0:
            self.cooldown -= 1
            return
        target = None
        best_dist = float('inf')
        for enemy in enemies:
            if not enemy.alive or enemy.targeted:
                continue
            dist = math.hypot(enemy.x - self.x, enemy.y - self.y)
            if dist < TOWER_RANGE and dist < best_dist:
                target = enemy
                best_dist = dist
        if target is not None:
            bullets.append(Bullet(self.x, self.y, target))
            target.targeted = True
            self.cooldown = TOWER_RELOAD

    def draw(self, surface):
        box = pygame.Rect(self.col * CELL + 5, self.row * CELL + 5,
                          CELL - 10, CELL - 10)
        pygame.draw.rect(surface, TOWER_COLOR, box, border_radius=5)
        pygame.draw.rect(surface, TOWER_EDGE, box, 2, border_radius=5)
        pygame.draw.circle(surface, TOWER_EDGE, (int(self.x), int(self.y)), 4)


class Bullet:
    def __init__(self, x, y, target):
        self.x = float(x)
        self.y = float(y)
        self.target = target
        self.active = True

    def update(self):
        if not self.target.alive:
            self.active = False
            return
        dx = self.target.x - self.x
        dy = self.target.y - self.y
        dist = math.hypot(dx, dy)
        if dist < BULLET_SPEED:
            self.target.hit()
            self.active = False
        else:
            self.x += dx / dist * BULLET_SPEED
            self.y += dy / dist * BULLET_SPEED

    def draw(self, surface):
        pygame.draw.circle(surface, BULLET_COLOR, (int(self.x), int(self.y)), 4)


def reset_game():
    """Set every piece of game state back to the start."""
    global enemies, towers, bullets, score, lives, coins
    global enemy_speed, spawn_ms, active_lanes, flash_text, flash_timer
    global game_over, paused, grid

    enemies = []
    towers = []
    bullets = []
    score = 0
    lives = START_LIVES
    coins = START_COINS
    enemy_speed = SPEED_BASE
    spawn_ms = SPAWN_BASE
    active_lanes = ['left']
    flash_text = ""
    flash_timer = 0
    game_over = False
    paused = False

    # Rebuild the grid: paths block building, everything else is open
    grid = [['open'] * ROWS for _ in range(COLS)]
    for cell in ALL_PATH_CELLS:
        grid[cell[0]][cell[1]] = 'path'
    for cell in ENTRY_CELLS:
        grid[cell[0]][cell[1]] = 'entry'
    grid[BASE[0]][BASE[1]] = 'base'

    # Only the first lane spawns; the others are switched off
    for lane, event_id in SPAWN_EVENTS.items():
        pygame.time.set_timer(event_id, spawn_ms if lane in active_lanes else 0)


def tower_cost():
    return TOWER_COST + COST_STEP * len(towers)


def enemy_hp():
    return min(HP_CAP, HP_BASE + score // HP_EVERY)


def can_build(col, row):
    if not (0 <= col < COLS and 0 <= row < ROWS):
        return False
    return grid[col][row] == 'open'


def rescale():
    """After every kill: speed up, spawn faster, maybe open a new lane."""
    global enemy_speed, spawn_ms, flash_text, flash_timer

    enemy_speed = min(SPEED_MAX, SPEED_BASE + (score // 10) * SPEED_STEP)
    new_spawn = max(SPAWN_MIN, SPAWN_BASE - (score // 10) * SPAWN_STEP)

    spawn_changed = new_spawn != spawn_ms
    spawn_ms = new_spawn

    for threshold, lane in UNLOCKS:
        if score >= threshold and lane not in active_lanes:
            active_lanes.append(lane)
            pygame.time.set_timer(SPAWN_EVENTS[lane], spawn_ms)
            flash_text = f"NEW LANE OPEN:  {lane.upper()}"
            flash_timer = 110

    if spawn_changed:
        for lane in active_lanes:
            pygame.time.set_timer(SPAWN_EVENTS[lane], spawn_ms)


def draw_grid():
    # Which cells belong to a lane that is currently active?
    active_cells = set()
    for lane in active_lanes:
        active_cells |= LANE_CELLS[lane]

    for row in range(ROWS):
        for col in range(COLS):
            x = col * CELL
            y = row * CELL
            cell = (col, row)
            cell_type = grid[col][row]

            if cell_type == 'base':
                color = BASE_COLOR
            elif cell_type == 'entry':
                color = ENTRY_COLOR if cell in active_cells else PATH_OFF
            elif cell_type == 'path':
                # dim if this lane has not opened yet
                color = PATH_ON if cell in active_cells else PATH_OFF
            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_build_preview():
    mouse_x, mouse_y = pygame.mouse.get_pos()
    if mouse_y >= ROWS * CELL or game_over or paused:
        return
    col = mouse_x // CELL
    row = mouse_y // CELL
    legal = can_build(col, row)
    affordable = coins >= tower_cost()
    color = OK_RING if (legal and affordable) else BAD_RING
    center_x = col * CELL + CELL // 2
    center_y = row * CELL + CELL // 2
    pygame.draw.rect(screen, color, (col * CELL, row * CELL, CELL, CELL), 2)
    pygame.draw.circle(screen, color, (center_x, center_y), int(TOWER_RANGE), 1)
    if legal and not affordable:
        warn = small_font.render(f"need {tower_cost()}", True, BAD_RING)
        screen.blit(warn, warn.get_rect(center=(center_x, center_y - CELL)))


def draw_hud():
    top = ROWS * CELL
    pygame.draw.rect(screen, (12, 16, 26), (0, top, WIDTH, HUD_H))
    pygame.draw.line(screen, GRID_COLOR, (0, top), (WIDTH, top), 1)

    line1 = f"SCORE {score}   LIVES {lives}   COINS {coins}   NEXT TOWER {tower_cost()}"
    screen.blit(font.render(line1, True, HUD_COLOR), (10, top + 10))

    lanes = ", ".join(lane.upper() for lane in active_lanes)
    line2 = f"enemy HP {enemy_hp()}   speed {enemy_speed:.2f}   lanes: {lanes}"
    screen.blit(small_font.render(line2, True, COIN_COLOR), (10, top + 34))

    hint = small_font.render("click=build  P=pause  R=restart", True, (110, 130, 155))
    screen.blit(hint, hint.get_rect(topright=(WIDTH - 10, top + 12)))


def draw_overlays():
    if flash_timer > 0:
        text = med_font.render(flash_text, True, WARN_COLOR)
        screen.blit(text, text.get_rect(center=(WIDTH // 2, 40)))

    if paused and not game_over:
        shade = pygame.Surface((WIDTH, ROWS * CELL), pygame.SRCALPHA)
        shade.fill((0, 0, 0, 130))
        screen.blit(shade, (0, 0))
        text = med_font.render("PAUSED — press P", True, (215, 215, 215))
        screen.blit(text, text.get_rect(center=(WIDTH // 2, ROWS * CELL // 2)))

    if game_over:
        shade = pygame.Surface((WIDTH, ROWS * CELL), pygame.SRCALPHA)
        shade.fill((0, 0, 0, 175))
        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 - 40)))
        final = med_font.render(f"Final score: {score}", True, (225, 225, 225))
        screen.blit(final, final.get_rect(center=(WIDTH // 2, ROWS * CELL // 2 + 15)))
        again = small_font.render("Press R to play again", True, (165, 165, 165))
        screen.blit(again, again.get_rect(center=(WIDTH // 2, ROWS * CELL // 2 + 55)))


# Start the game
reset_game()

running = True

while running:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False

        if event.type == pygame.KEYDOWN:
            if event.key == pygame.K_r:
                reset_game()
            elif event.key == pygame.K_p:
                paused = not paused

        if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
            mouse_x, mouse_y = event.pos
            if mouse_y < ROWS * CELL and not game_over and not paused:
                col = mouse_x // CELL
                row = mouse_y // CELL
                if can_build(col, row) and coins >= tower_cost():
                    coins -= tower_cost()
                    towers.append(Tower(col, row))
                    grid[col][row] = 'tower'

        # One spawn event per lane — only the active ones are running
        for lane, event_id in SPAWN_EVENTS.items():
            if event.type == event_id and not game_over and not paused:
                if lane in active_lanes:
                    enemies.append(Enemy(PATHS[lane], enemy_speed, enemy_hp()))

    # Update
    if not game_over and not paused:
        for enemy in enemies:
            enemy.targeted = False

        for tower in towers:
            tower.update(enemies, bullets)

        for enemy in enemies[:]:
            enemy.update()
            if not enemy.alive:
                enemies.remove(enemy)
                if enemy.reached_base:
                    lives -= 1
                    if lives <= 0:
                        lives = 0
                        game_over = True
                        for event_id in SPAWN_EVENTS.values():
                            pygame.time.set_timer(event_id, 0)
                else:
                    score += 1
                    coins += COINS_PER_KILL
                    rescale()

        for bullet in bullets[:]:
            bullet.update()
            if not bullet.active:
                bullets.remove(bullet)

        if flash_timer > 0:
            flash_timer -= 1

    # Draw
    screen.fill(BG_COLOR)
    draw_grid()
    for tower in towers:
        tower.draw(screen)
    for enemy in enemies:
        enemy.draw(screen)
    for bullet in bullets:
        bullet.draw(screen)
    draw_build_preview()
    draw_hud()
    draw_overlays()

    pygame.display.flip()
    clock.tick(60)

pygame.quit()