Tower Defense — Milestone 4

Coins & The Price of Towers

# TOWER DEFENSE — MILESTONE 4: Coins & The Price of Towers
# =============================================================================
# TARGET (show me when this works):
#   You start with 50 coins. Your first tower costs 18.
#   Every kill pays you 6 coins.
#   Each tower you own makes the NEXT tower cost 6 more (18, 24, 30, 36 ...).
#   Hovering over a cell shows a ring: GREEN = you can build here,
#   RED = blocked, or you cannot afford it (it tells you the price you need).
#
# NEW IN THIS FILE (compared to Milestone 3):
#   - coins, and a tower_cost() that grows as you build
#   - the hover preview ring that shows a tower's range BEFORE you buy
#   - towers now refuse to shoot an enemy another tower already aimed at
#
# THE TWO IDEAS TO UNDERSTAND:
#
#   1. ESCALATING COST IS WHAT MAKES THIS A GAME.
#      If towers were free (Milestone 3) the best move is always "build more".
#      There is no decision to make. Once each tower costs more than the last,
#      you have to ask "is this the BEST square, or just an okay square?"
#      That question is the whole game.
#
#   2. DON'T WASTE SHOTS — the `targeted` flag.
#      Without it, three towers all fire at the same enemy. One shot kills it
#      and the other two bullets are thrown away. So each frame we clear every
#      enemy's `targeted` flag, and a tower marks the enemy it picked so the
#      other towers move on to a different one.
#
# NEXT (Milestone 5): enemies that take more than one hit to kill.
# =============================================================================

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 4")
clock = pygame.time.Clock()
font = pygame.font.SysFont("Courier", 18, bold=True)
small_font = pygame.font.SysFont("Courier", 15, 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)
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)
OK_RING = (80, 220, 110)
BAD_RING = (220, 70, 70)

# Settings
ENEMY_SPEED = 1.5
SPAWN_MS = 2000
START_LIVES = 25
TOWER_RANGE = 2.5 * CELL
TOWER_RELOAD = 60
BULLET_SPEED = 5

# Economy — these exact numbers were chosen by running simulations
START_COINS = 50
TOWER_COST = 18       # price of your very first tower
COST_STEP = 6         # every tower you own adds this to the next price
COINS_PER_KILL = 6

BASE = (10, 7)
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):
    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


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
        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
        self.targeted = False      # cleared every frame; see the game loop

    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.alive = False

    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)


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:
            # skip the dead, and skip anyone another tower already picked
            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 tower_cost():
    """The more towers you own, the more the next one costs."""
    return TOWER_COST + COST_STEP * len(towers)


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


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_build_preview():
    """Show, before you click, whether this square is a legal build."""
    mouse_x, mouse_y = pygame.mouse.get_pos()
    if mouse_y >= ROWS * CELL or lives <= 0:
        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(score, lives, coins):
    top = ROWS * CELL
    pygame.draw.rect(screen, (12, 16, 26), (0, top, WIDTH, HUD_H))
    line1 = f"SCORE {score}   LIVES {lives}   COINS {coins}"
    screen.blit(font.render(line1, True, HUD_COLOR), (10, top + 10))
    line2 = f"next tower costs {tower_cost()}   (+{COINS_PER_KILL} coins per kill)"
    screen.blit(small_font.render(line2, True, COIN_COLOR), (10, top + 34))


# Game state
enemies = []
towers = []
bullets = []
score = 0
lives = START_LIVES
coins = START_COINS

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 == pygame.MOUSEBUTTONDOWN and event.button == 1:
            mouse_x, mouse_y = event.pos
            if mouse_y < ROWS * CELL and lives > 0:
                col = mouse_x // CELL
                row = mouse_y // CELL
                # Two things must be true: legal square AND enough coins
                if can_build(col, row) and coins >= tower_cost():
                    coins -= tower_cost()
                    towers.append(Tower(col, row))
                    grid[col][row] = 'tower'

        if event.type == SPAWN_EVENT and lives > 0:
            enemies.append(Enemy(PATH, ENEMY_SPEED))

    # Clear every enemy's target flag before the towers choose again
    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
            else:
                score += 1
                coins += COINS_PER_KILL

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

    # 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(score, lives, coins)

    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()