728x90

11강에서는 총알(Bullet)로 공격을 구현했습니다.
이번 12강에서는 게임 진행을 “무한 스폰”에서 “웨이브 단위”로 바꿉니다.
- 웨이브 번호 표시
- 웨이브마다 스폰할 적 수 지정
- 적을 다 스폰하면 웨이브 종료 대기
- 일정 시간 후 다음 웨이브 시작
1. 웨이브에 필요한 변수
웨이브 시스템은 아래 변수로 구성합니다.
- wave : 현재 웨이브 번호
- to_spawn : 이번 웨이브에 생성할 적 수
- spawned : 이번 웨이브에서 이미 생성한 수
- wave_state : 진행 상태("SPAWNING", "WAITING")
- next_wave_time : 다음 웨이브 시작 예정 시간(ms)
wave = 1
to_spawn = 5
spawned = 0
wave_state = "SPAWNING"
next_wave_time = 0
2. 웨이브마다 적 수 증가 규칙
이번 강의에서는 단순하게 웨이브가 올라갈수록 적 수를 증가시킵니다.
to_spawn = 5 + (wave - 1) * 2
3. 스폰 로직 변경(웨이브 기반)
이제 “1초마다 무한 스폰”이 아니라,
spawned < to_spawn일 때만 스폰합니다.
if wave_state == "SPAWNING":
if spawned < to_spawn and now - last_spawn_time >= spawn_interval:
enemies.append(Enemy(path_points))
spawned += 1
last_spawn_time = now
4. 웨이브 종료 조건
이번 웨이브에서
- 스폰할 적을 전부 생성했고
- 화면에 남은 적이 0이면
웨이브를 종료하고 대기 상태로 전환합니다.
if spawned >= to_spawn and len(enemies) == 0:
wave_state = "WAITING"
next_wave_time = now + 3000
5. 다음 웨이브 시작
대기 시간이 지나면 웨이브를 1 올리고, 변수들을 초기화합니다.
if wave_state == "WAITING" and now >= next_wave_time:
wave += 1
to_spawn = 5 + (wave - 1) * 2
spawned = 0
wave_state = "SPAWNING"
6. UI에 웨이브 표시
현재 웨이브와 남은 스폰 수를 표시합니다.
screen.blit(font.render(f"Wave: {wave}", True, (255, 255, 255)), (10, 100))
screen.blit(font.render(f"Spawn: {spawned}/{to_spawn}", True, (200, 200, 200)), (10, 130))
7. 실행 결과

전체 코드
더보기
import pygame
import sys
pygame.init()
# -----------------------------
# 화면 설정
# -----------------------------
SCREEN_WIDTH = 800
SCREEN_HEIGHT = 600
screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT))
pygame.display.set_caption("Tower Defense")
clock = pygame.time.Clock()
FPS = 60
# -----------------------------
# 경로 설정
# -----------------------------
path_points = [
(0, 300),
(100, 300),
(100, 100),
(300, 100),
(300, 500),
(650, 500),
(650, 300),
(450, 300),
(450, 100),
(700, 100),
(700, 200),
(800, 200)
]
PATH_WIDTH = 40
TOWER_RADIUS = 16
TOWER_COST = 50
TOWER_RANGE = 140
# -----------------------------
# 유틸: 점-선분 거리
# -----------------------------
def point_to_segment_distance(px, py, ax, ay, bx, by):
abx = bx - ax
aby = by - ay
apx = px - ax
apy = py - ay
ab_len_sq = abx * abx + aby * aby
if ab_len_sq == 0:
return ((px - ax) ** 2 + (py - ay) ** 2) ** 0.5
t = (apx * abx + apy * aby) / ab_len_sq
t = max(0.0, min(1.0, t))
cx = ax + abx * t
cy = ay + aby * t
dx = px - cx
dy = py - cy
return (dx * dx + dy * dy) ** 0.5
# -----------------------------
# 유틸: 경로 근처 설치 금지
# -----------------------------
def is_on_path(pos, path_points):
px, py = pos
threshold = PATH_WIDTH / 2 + TOWER_RADIUS
for i in range(len(path_points) - 1):
ax, ay = path_points[i]
bx, by = path_points[i + 1]
if point_to_segment_distance(px, py, ax, ay, bx, by) <= threshold:
return True
return False
# -----------------------------
# 유틸: 타워 겹침 방지
# -----------------------------
def is_overlapping_tower(pos, towers):
px, py = pos
min_dist = TOWER_RADIUS * 2
for t in towers:
dx = px - t.x
dy = py - t.y
if (dx * dx + dy * dy) ** 0.5 < min_dist:
return True
return False
# -----------------------------
# Enemy 클래스
# -----------------------------
class Enemy:
def __init__(self, path_points, speed=2):
self.path_points = path_points
self.speed = speed
self.x, self.y = path_points[0]
self.target_index = 1
self.max_hp = 50
self.hp = self.max_hp
self.reward = 10
self.reached_end = False
self.is_dead = False
self.radius = 12
def update(self):
if self.reached_end or self.is_dead:
return
if self.target_index >= len(self.path_points):
self.reached_end = True
return
tx, ty = self.path_points[self.target_index]
dx = tx - self.x
dy = ty - self.y
dist = (dx * dx + dy * dy) ** 0.5
if dist == 0:
self.target_index += 1
return
self.x += (dx / dist) * self.speed
self.y += (dy / dist) * self.speed
if dist <= self.speed:
self.x, self.y = tx, ty
self.target_index += 1
if self.target_index >= len(self.path_points):
self.reached_end = True
def draw(self, screen):
pygame.draw.circle(screen, (220, 80, 80), (int(self.x), int(self.y)), self.radius)
# HP 바
bar_width = 24
bar_height = 4
hp_ratio = max(0, self.hp) / self.max_hp
pygame.draw.rect(screen, (60, 60, 60), (self.x - 12, self.y - 20, bar_width, bar_height))
pygame.draw.rect(screen, (80, 220, 80), (self.x - 12, self.y - 20, bar_width * hp_ratio, bar_height))
# -----------------------------
# Bullet 클래스
# -----------------------------
class Bullet:
def __init__(self, start_pos, target, speed=8, damage=10):
self.x, self.y = start_pos
self.target = target
self.speed = speed
self.damage = damage
self.radius = 4
self.is_dead = False
def update(self):
if self.target.is_dead or self.target.reached_end:
self.is_dead = True
return
tx, ty = self.target.x, self.target.y
dx = tx - self.x
dy = ty - self.y
dist = (dx * dx + dy * dy) ** 0.5
if dist == 0:
dist = 0.0001
self.x += (dx / dist) * self.speed
self.y += (dy / dist) * self.speed
hit_dist = self.radius + self.target.radius
if dist <= hit_dist:
self.target.hp -= self.damage
if self.target.hp <= 0:
self.target.is_dead = True
self.is_dead = True
def draw(self, screen):
pygame.draw.circle(screen, (240, 240, 240), (int(self.x), int(self.y)), self.radius)
# -----------------------------
# Tower 클래스
# -----------------------------
class Tower:
def __init__(self, pos):
self.x, self.y = pos
self.radius = TOWER_RADIUS
self.range = TOWER_RANGE
self.damage = 10
self.fire_delay = 500
self.last_fire_time = 0
self.target = None
def find_target(self, enemies):
self.target = None
best_dist = None
for e in enemies:
if e.is_dead or e.reached_end:
continue
dx = e.x - self.x
dy = e.y - self.y
dist = (dx * dx + dy * dy) ** 0.5
if dist <= self.range:
if best_dist is None or dist < best_dist:
best_dist = dist
self.target = e
def try_attack(self, bullets):
if not self.target:
return
if self.target.is_dead or self.target.reached_end:
return
now = pygame.time.get_ticks()
if now - self.last_fire_time >= self.fire_delay:
bullets.append(Bullet((self.x, self.y), self.target, speed=8, damage=self.damage))
self.last_fire_time = now
def draw(self, screen, show_range=False):
pygame.draw.circle(screen, (80, 160, 255), (self.x, self.y), self.radius)
pygame.draw.circle(screen, (30, 30, 30), (self.x, self.y), self.radius, 3)
if show_range:
pygame.draw.circle(screen, (120, 120, 120), (self.x, self.y), self.range, 1)
if self.target and not self.target.is_dead and not self.target.reached_end:
pygame.draw.line(screen, (255, 255, 255), (self.x, self.y), (int(self.target.x), int(self.target.y)), 2)
# -----------------------------
# 게임 변수
# -----------------------------
enemies = []
towers = []
bullets = []
base_hp = 20
gold = 100
font = pygame.font.SysFont(None, 32)
selected_tower_index = -1
# -----------------------------
# 웨이브 변수
# -----------------------------
wave = 1
to_spawn = 5
spawned = 0
wave_state = "SPAWNING" # SPAWNING / WAITING
next_wave_time = 0
spawn_interval = 500 # 웨이브 내 스폰 간격(ms)
last_spawn_time = 0
# -----------------------------
# 메인 루프
# -----------------------------
while True:
for event in pygame.event.get():
if event.type == pygame.QUIT:
pygame.quit()
sys.exit()
# 타워 설치(좌클릭)
if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
click_pos = pygame.mouse.get_pos()
if not is_on_path(click_pos, path_points) and not is_overlapping_tower(click_pos, towers):
if gold >= TOWER_COST:
towers.append(Tower(click_pos))
gold -= TOWER_COST
selected_tower_index = len(towers) - 1
game_over = base_hp <= 0
now = pygame.time.get_ticks()
if not game_over:
# -----------------------------
# 웨이브 스폰 제어
# -----------------------------
if wave_state == "SPAWNING":
if spawned < to_spawn and now - last_spawn_time >= spawn_interval:
enemies.append(Enemy(path_points))
spawned += 1
last_spawn_time = now
# 스폰 완료 + 남은 적 없음 -> 웨이브 대기
if spawned >= to_spawn and len(enemies) == 0:
wave_state = "WAITING"
next_wave_time = now + 3000 # 3초 대기
elif wave_state == "WAITING":
# 대기 시간이 끝나면 다음 웨이브 시작
if now >= next_wave_time:
wave += 1
to_spawn = 5 + (wave - 1) * 2
spawned = 0
wave_state = "SPAWNING"
last_spawn_time = now
# 적 업데이트
for enemy in enemies:
enemy.update()
# 타워 타겟/공격
for tower in towers:
tower.find_target(enemies)
tower.try_attack(bullets)
# 총알 업데이트
for b in bullets:
b.update()
# 도착한 적 처리(베이스 HP 감소)
arrived = [e for e in enemies if e.reached_end]
base_hp -= len(arrived)
# 죽은 적 처리(골드 획득)
dead = [e for e in enemies if e.is_dead]
gold += sum(e.reward for e in dead)
# 적/총알 정리
enemies = [e for e in enemies if not e.reached_end and not e.is_dead]
bullets = [b for b in bullets if not b.is_dead]
# -----------------------------
# 화면 그리기
# -----------------------------
screen.fill((30, 30, 30))
pygame.draw.lines(screen, (200, 180, 100), False, path_points, PATH_WIDTH)
for i, tower in enumerate(towers):
tower.draw(screen, show_range=(i == selected_tower_index))
for enemy in enemies:
enemy.draw(screen)
for b in bullets:
b.draw(screen)
# UI
screen.blit(font.render(f"HP: {base_hp}", True, (255, 255, 255)), (10, 10))
screen.blit(font.render(f"Gold: {gold}", True, (255, 255, 0)), (10, 40))
screen.blit(font.render(f"Wave: {wave}", True, (255, 255, 255)), (10, 100))
screen.blit(font.render(f"Spawn: {spawned}/{to_spawn}", True, (200, 200, 200)), (10, 130))
if wave_state == "WAITING" and not game_over:
wait_left = max(0, (next_wave_time - now) // 1000)
screen.blit(font.render(f"Next wave in: {wait_left}s", True, (200, 200, 200)), (10, 160))
if game_over:
over_text = font.render("GAME OVER", True, (255, 255, 255))
screen.blit(over_text, (SCREEN_WIDTH // 2 - over_text.get_width() // 2, SCREEN_HEIGHT // 2))
pygame.display.update()
clock.tick(FPS)
728x90
'⚙️ Python > 🎮 Pygame 실전' 카테고리의 다른 글
| [Pygame] 🏰 2D 타워 디펜스 게임 만들기 14강 | 타워 강화 시스템 만들기 (0) | 2026.02.14 |
|---|---|
| [Pygame] 🏰 2D 타워 디펜스 게임 만들기 13강 | 적 종류 추가하기 (0) | 2026.02.13 |
| [Pygame] 🏰 2D 타워 디펜스 게임 만들기 11강 | 총알 만들기 (0) | 2026.02.11 |
| [Pygame] 🏰 2D 타워 디펜스 게임 만들기 10강 | 타워 공격 구현하기 (0) | 2026.02.10 |
| [Pygame] 🏰 2D 타워 디펜스 게임 만들기 9강 | 타워 사거리 & 타겟 선택 구현하기 (0) | 2026.02.09 |