완성된 모습 — 12번 챕터까지 만들면 이렇게 동작합니다
각 챕터는 이전 챕터의 전체 코드 + 새로 추가된 부분입니다.  파란색으로 밑칠된 줄 이 이번 챕터에서 새로 생긴 부분입니다.
이 게임에서 배우는 것: 벽돌깨기에서는 각도를 "맞은 위치"로 간접적으로 정했습니다. 이번에는 각도를 직접 숫자로 정하고, sin과 cos으로 속도를 만들어냅니다. 2번 챕터와 4번 챕터가 이 프로젝트의 핵심이니 천천히 읽으세요.

1지형과 탱크 그리기

먼저 땅과 탱크 두 대를 그립니다. 여기서 중요한 결정을 하나 합니다 — 땅을 사각형이 아니라 "높이 리스트"로 관리합니다.

ground[0]   = 430   # x=0 위치에서 땅 표면의 높이
ground[1]   = 430.2
ground[2]   = 430.4
...
ground[899] = 445   # x=899 위치에서 땅 표면의 높이

x 픽셀마다 "이 자리의 땅은 어디까지 올라와 있나"를 숫자 하나로 저장하는 것입니다. 처음엔 번거로워 보이지만, 이렇게 해두면 11번 챕터에서 포탄이 땅을 파는 기능을 아주 쉽게 만들 수 있습니다. 리스트의 숫자만 바꾸면 땅 모양이 바뀌니까요.

import pygame
import math

pygame.init()
W, H = 900, 600
screen = pygame.display.set_mode((W, H))
pygame.display.set_caption("탱크 게임")
clock = pygame.time.Clock()

SKY = (140, 185, 225)
GROUND_COLOR = (80, 120, 70)
TANK_W, TANK_H = 46, 20

# 땅: x 픽셀마다 표면의 y좌표를 저장한다
ground = []
for x in range(W):
    y = H - 150 - 38 * math.sin(x / 120) - 18 * math.sin(x / 43)
    ground.append(y)

def ground_y(x):
    # x 위치의 땅 높이를 돌려준다 (화면 밖이면 가장자리 값)
    xi = max(0, min(W - 1, int(x)))
    return ground[xi]

tanks = [
    {"x": 120.0, "color": (55, 105, 200), "name": "내 탱크"},
    {"x": W - 120.0, "color": (200, 80, 70), "name": "적 탱크"},
]

def tank_rect(t):
    # 탱크는 항상 그 자리의 땅 위에 서 있다
    y = ground_y(t["x"])
    return pygame.Rect(int(t["x"] - TANK_W / 2), int(y - TANK_H), TANK_W, TANK_H)

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

    screen.fill(SKY)

    # 땅: 왼쪽아래 → 표면을 따라 → 오른쪽아래 를 이어 다각형으로 채운다
    points = [(0, H)] + [(x, ground[x]) for x in range(W)] + [(W, H)]
    pygame.draw.polygon(screen, GROUND_COLOR, points)

    for t in tanks:
        pygame.draw.rect(screen, t["color"], tank_rect(t), border_radius=6)

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

pygame.quit()

언덕은 어떻게 만들었을까?

38 * math.sin(x / 120)이 물결 모양을 만듭니다. sin−1과 +1 사이를 부드럽게 오가는 함수라서, 여기에 38을 곱하면 위아래로 38픽셀씩 출렁이는 언덕이 됩니다.

여기서 sin을 "물결을 만드는 도구"로 처음 만났습니다. 다음 챕터에서는 같은 sin각도를 방향으로 바꾸는 도구로 쓰입니다. 사실 이 둘은 같은 원리인데, 2번 챕터에서 그 이유를 자세히 설명합니다.
직접 해보기 38100으로 바꿔 험한 산을 만들어보세요. x / 120x / 30으로 바꾸면 어떻게 되나요? 두 sin 중 하나를 지우면 땅이 어떻게 달라지나요?

2포신 각도 조절 — sin·cos 완전 정복 ★

이번 챕터는 이 프로젝트에서 가장 중요합니다. 코드는 몇 줄 안 되지만, 그 몇 줄을 이해하는 게 전부입니다. 천천히 읽으세요.

우리가 풀어야 할 문제는 이것입니다: "각도가 45도일 때, 포신 끝은 화면의 어디일까?"


📐 1부 — 각도란 무엇인가

각도의 기준: 0도는 "오른쪽"

수학에서는 오른쪽 방향을 0도로 정하고, 반시계 방향(위쪽)으로 돌면서 각도가 커진다고 약속했습니다. 우리도 이 약속을 그대로 씁니다.

포신이 시작하는 곳 45° 90° 135° 180° 각도
각도포신이 향하는 곳언제 쓰나
오른쪽 수평바로 앞 목표
45°오른쪽 위 대각선가장 멀리 보낼 때
90°똑바로 위제자리에 떨어짐
135°왼쪽 위 대각선왼쪽 목표 (적 탱크용)
180°왼쪽 수평왼쪽 바로 앞

📐 2부 — cos과 sin은 무슨 뜻인가

각도를 정했다고 해서 컴퓨터가 "어느 쪽인지" 아는 게 아닙니다. 컴퓨터는 "가로로 몇 칸, 세로로 몇 칸"만 이해합니다. 그래서 각도를 가로·세로 숫자로 번역해야 하는데, 그 번역기가 바로 cossin입니다.

한 줄 요약
· cos(각도) = 그 방향으로 1만큼 갈 때 가로로 가는 정도
· sin(각도) = 그 방향으로 1만큼 갈 때 세로로 가는 정도
길이 1 (45° 방향) cos 45° = 0.71 가로로 간 정도 sin 45° = 0.71 세로로 간 정도 45° 45도 방향으로 1만큼 가면 가로로 0.71 세로로 0.71 만큼 이동한 셈이다. 포신 길이가 42라면? 가로 42 × 0.71 = 29.8 세로 42 × 0.71 = 29.8

cossin은 "비율"입니다. 항상 −1에서 1 사이의 값이고, 여기에 실제 길이를 곱하면 실제 거리가 나옵니다.

각도cos (가로 비율)sin (세로 비율)포신 길이 42일 때
1.000.00가로 +42, 세로 0 (수평)
30°0.870.50가로 +36, 세로 21 위
45°0.710.71가로 +30, 세로 30 위
60°0.500.87가로 +21, 세로 36 위
90°0.001.00가로 0, 세로 42 위 (수직)
135°−0.710.71가로 −30(왼쪽), 세로 30 위
180°−1.000.00가로 −42(왼쪽), 세로 0

각도가 커질수록 cos은 작아지고 sin은 커집니다. 포신을 위로 들수록 앞으로는 덜 나가고 위로 더 향하니까 당연하죠. 90도를 넘으면 cos이 음수가 되는데, 이건 "가로로 왼쪽" 이라는 뜻입니다.


📐 3부 — 코드로 옮길 때 주의할 점 두 가지

주의 1. 파이썬은 "도"가 아니라 "라디안"을 쓴다

math.cos(45)라고 쓰면 틀립니다. 파이썬의 cos, sin은 각도 단위로 라디안을 받기 때문입니다. 라디안은 각도를 재는 또 다른 단위입니다.

도(degree)라디안(radian)
0
90°1.5708 (π/2)
180°3.1416 (π)
360°6.2832 (2π)

다행히 외울 필요 없습니다. math.radians()가 도를 라디안으로 바꿔줍니다.

# 틀린 코드 — 45를 라디안으로 해석해서 엉뚱한 값이 나온다
x = math.cos(45)          # 0.5253... (45라디안 = 약 2578도!)

# 맞는 코드
x = math.cos(math.radians(45))   # 0.7071 ✓

주의 2. 화면의 y축은 위아래가 뒤집혀 있다

수학 시간에 배우는 좌표평면은 위로 갈수록 y가 커집니다. 그런데 컴퓨터 화면은 아래로 갈수록 y가 커집니다(맨 위가 0). 그래서 sin 값을 그대로 쓰면 포신이 아래를 향합니다.

수학 좌표평면 y가 커짐 ↑ sin 그대로 = 위 파이게임 화면 좌표 y가 커짐 ↓ sin 그대로 = 아래!

해결은 간단합니다. sin 앞에 마이너스를 붙이면 됩니다.

bx = t["x"] + math.cos(rad) * BARREL_LEN      # 가로는 그대로
by = 포신시작y - math.sin(rad) * BARREL_LEN   # 세로는 부호를 뒤집는다 ★
마이너스 하나가 초보자가 가장 많이 틀리는 부분입니다. 포신이나 포탄이 자꾸 땅으로 처박힌다면 십중팔구 이 부호 문제입니다.

이제 코드로

import pygame
import math

pygame.init()
W, H = 900, 600
screen = pygame.display.set_mode((W, H))
pygame.display.set_caption("탱크 게임")
clock = pygame.time.Clock()
font = pygame.font.SysFont("malgungothic", 20)

SKY = (140, 185, 225)
GROUND_COLOR = (80, 120, 70)
TANK_W, TANK_H = 46, 20
BARREL_LEN = 42                                # 포신 길이

ground = []
for x in range(W):
    y = H - 150 - 38 * math.sin(x / 120) - 18 * math.sin(x / 43)
    ground.append(y)

def ground_y(x):
    xi = max(0, min(W - 1, int(x)))
    return ground[xi]

tanks = [
    {"x": 120.0, "angle": 45.0, "color": (55, 105, 200), "name": "내 탱크"},
    {"x": W - 120.0, "angle": 135.0, "color": (200, 80, 70), "name": "적 탱크"},
]

def tank_rect(t):
    y = ground_y(t["x"])
    return pygame.Rect(int(t["x"] - TANK_W / 2), int(y - TANK_H), TANK_W, TANK_H)

def barrel_end(t):
    # 각도를 가로·세로 거리로 번역해서 포신 끝 좌표를 구한다
    rad = math.radians(t["angle"])             # 도 → 라디안
    bx = t["x"] + math.cos(rad) * BARREL_LEN   # 가로: cos
    by = (ground_y(t["x"]) - TANK_H) - math.sin(rad) * BARREL_LEN   # 세로: -sin ★
    return bx, by

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

    # ← → 키로 각도를 조절한다 (0도 ~ 180도)
    keys = pygame.key.get_pressed()
    me = tanks[0]
    if keys[pygame.K_LEFT]:
        me["angle"] = min(180.0, me["angle"] + 0.8)
    if keys[pygame.K_RIGHT]:
        me["angle"] = max(0.0, me["angle"] - 0.8)

    screen.fill(SKY)
    points = [(0, H)] + [(x, ground[x]) for x in range(W)] + [(W, H)]
    pygame.draw.polygon(screen, GROUND_COLOR, points)

    for t in tanks:
        r = tank_rect(t)
        pygame.draw.rect(screen, t["color"], r, border_radius=6)
        bx, by = barrel_end(t)
        pygame.draw.line(screen, t["color"], (t["x"], r.top), (bx, by), 5)

    screen.blit(font.render(f"각도 {me['angle']:.0f}°", True, (25, 30, 40)), (16, 14))

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

pygame.quit()

실행하고 ← → 키를 눌러보세요. 포신이 부드럽게 돕니다. 단 5줄(barrel_end 함수)로 각도를 화면 위 좌표로 바꾼 것입니다.

직접 해보기
  • math.radians(t["angle"])에서 math.radians를 빼고 math.cos(t["angle"])로 바꿔보세요. 포신이 어떻게 미쳐 날뛰는지 확인하면 라디안의 존재 이유를 절대 안 잊습니다.
  • - math.sin(rad)의 마이너스를 지워보세요. 포신이 어디를 향하나요?
  • BARREL_LEN을 150으로 늘려보세요. 각도를 바꿀 때 포신 끝이 원을 그리며 도는 게 잘 보입니다 — 그게 바로 2부에서 본 원입니다.

3발사 힘 조절

각도만으로는 부족합니다. 같은 45도라도 살살 쏘면 가까이, 세게 쏘면 멀리 날아가야 하니까요. 그래서 power(힘) 값을 추가하고 ↑↓ 키로 조절합니다.

그리고 숫자만 보여주면 감이 안 오니 힘 게이지 막대도 함께 그립니다.

import pygame
import math

pygame.init()
W, H = 900, 600
screen = pygame.display.set_mode((W, H))
pygame.display.set_caption("탱크 게임")
clock = pygame.time.Clock()
font = pygame.font.SysFont("malgungothic", 20)

SKY = (140, 185, 225)
GROUND_COLOR = (80, 120, 70)
TANK_W, TANK_H = 46, 20
BARREL_LEN = 42

ground = []
for x in range(W):
    y = H - 150 - 38 * math.sin(x / 120) - 18 * math.sin(x / 43)
    ground.append(y)

def ground_y(x):
    xi = max(0, min(W - 1, int(x)))
    return ground[xi]

tanks = [
    {"x": 120.0, "angle": 45.0, "power": 55.0, "color": (55, 105, 200), "name": "내 탱크"},
    {"x": W - 120.0, "angle": 135.0, "power": 55.0, "color": (200, 80, 70), "name": "적 탱크"},
]

def tank_rect(t):
    y = ground_y(t["x"])
    return pygame.Rect(int(t["x"] - TANK_W / 2), int(y - TANK_H), TANK_W, TANK_H)

def barrel_end(t):
    rad = math.radians(t["angle"])
    bx = t["x"] + math.cos(rad) * BARREL_LEN
    by = (ground_y(t["x"]) - TANK_H) - math.sin(rad) * BARREL_LEN
    return bx, by

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

    keys = pygame.key.get_pressed()
    me = tanks[0]
    if keys[pygame.K_LEFT]:
        me["angle"] = min(180.0, me["angle"] + 0.8)
    if keys[pygame.K_RIGHT]:
        me["angle"] = max(0.0, me["angle"] - 0.8)
    if keys[pygame.K_UP]:
        me["power"] = min(100.0, me["power"] + 0.6)    # 최대 100
    if keys[pygame.K_DOWN]:
        me["power"] = max(5.0, me["power"] - 0.6)      # 최소 5

    screen.fill(SKY)
    points = [(0, H)] + [(x, ground[x]) for x in range(W)] + [(W, H)]
    pygame.draw.polygon(screen, GROUND_COLOR, points)

    for t in tanks:
        r = tank_rect(t)
        pygame.draw.rect(screen, t["color"], r, border_radius=6)
        bx, by = barrel_end(t)
        pygame.draw.line(screen, t["color"], (t["x"], r.top), (bx, by), 5)

    screen.blit(font.render(f"각도 {me['angle']:.0f}°", True, (25, 30, 40)), (16, 14))
    screen.blit(font.render(f"힘 {me['power']:.0f}", True, (25, 30, 40)), (16, 40))

    # 힘 게이지: 회색 배경 위에 힘만큼 주황색을 채운다
    gauge = pygame.Rect(16, 68, 200, 14)
    pygame.draw.rect(screen, (230, 230, 230), gauge, border_radius=7)
    filled = pygame.Rect(16, 68, int(200 * me["power"] / 100), 14)
    pygame.draw.rect(screen, (240, 150, 60), filled, border_radius=7)

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

pygame.quit()
게이지 그리는 방법은 간단합니다. 같은 위치에 회색 막대를 먼저 그리고, 그 위에 힘에 비례한 길이만큼 주황색 막대를 덮어 그립니다. 200 * power / 100은 "전체 길이 200 중에 power%만큼"이라는 뜻입니다. 벽돌깨기와 피하기 게임의 체력·점수 표시도 같은 원리로 만들 수 있습니다.
직접 해보기 힘이 80을 넘으면 게이지 색이 빨강으로 바뀌게 만들어보세요. (힌트: if me["power"] > 80:로 색을 골라서 변수에 담아두기)

4포탄 발사 — 각도와 힘을 속도로 바꾸기 ★

드디어 발사입니다. 여기서 2번 챕터에서 배운 sin·cos이 그대로 다시 쓰입니다. 포신 끝 좌표를 구할 때 썼던 공식이, 이번에는 포탄의 속도를 구하는 데 쓰입니다.

완전히 같은 구조입니다
# 2번 챕터 — 포신 끝이 어디인가 (각도 + 길이)
bx = x + math.cos(rad) * BARREL_LEN
by = y - math.sin(rad) * BARREL_LEN

# 4번 챕터 — 포탄이 얼마나 빠른가 (각도 + 힘)
vx =     math.cos(rad) * speed
vy =   - math.sin(rad) * speed
길이 자리에 힘을 넣으면 속도가 됩니다. "얼마나 멀리 있는 점인가"와 "얼마나 빠른가"는 수학적으로 똑같은 계산입니다.

왜 POWER_SCALE로 나눌까?

힘은 플레이어에게 보여줄 때 0~100이 직관적입니다. 그런데 속도 100픽셀/프레임이면 초당 6000픽셀이라 포탄이 순식간에 사라집니다. 그래서 POWER_SCALE = 0.22를 곱해 보기 좋은 숫자(0~100)를 실제 속도(0~22)로 변환합니다.

각도 45°, 힘 55 → 속도는? 각도 45° 힘 55 speed = 55 × 0.22 = 12.1 vx = 12.1×0.71 = 8.6 vy = −8.6 속도 12.1 vx = 8.6 vy = −8.6 한 프레임마다 가로로 8.6픽셀, 위로 8.6픽셀 움직인다는 뜻

포탄이 날아가는 방식은 벽돌깨기의 공과 완전히 똑같습니다x += vx, y += vy. 새로운 건 "처음 vx, vy를 각도와 힘으로 만든다"는 것뿐입니다.

import pygame
import math

pygame.init()
W, H = 900, 600
screen = pygame.display.set_mode((W, H))
pygame.display.set_caption("탱크 게임")
clock = pygame.time.Clock()
font = pygame.font.SysFont("malgungothic", 20)

SKY = (140, 185, 225)
GROUND_COLOR = (80, 120, 70)
TANK_W, TANK_H = 46, 20
BARREL_LEN = 42
POWER_SCALE = 0.22                             # 힘(0~100)을 실제 속도로
SHELL_R = 5

ground = []
for x in range(W):
    y = H - 150 - 38 * math.sin(x / 120) - 18 * math.sin(x / 43)
    ground.append(y)

def ground_y(x):
    xi = max(0, min(W - 1, int(x)))
    return ground[xi]

tanks = [
    {"x": 120.0, "angle": 45.0, "power": 55.0, "color": (55, 105, 200), "name": "내 탱크"},
    {"x": W - 120.0, "angle": 135.0, "power": 55.0, "color": (200, 80, 70), "name": "적 탱크"},
]
shell = None                                   # 날아가는 포탄 (없으면 None)

def tank_rect(t):
    y = ground_y(t["x"])
    return pygame.Rect(int(t["x"] - TANK_W / 2), int(y - TANK_H), TANK_W, TANK_H)

def barrel_end(t):
    rad = math.radians(t["angle"])
    bx = t["x"] + math.cos(rad) * BARREL_LEN
    by = (ground_y(t["x"]) - TANK_H) - math.sin(rad) * BARREL_LEN
    return bx, by

def fire(t):
    # 각도와 힘으로 포탄의 처음 속도를 만든다
    global shell
    rad = math.radians(t["angle"])
    speed = t["power"] * POWER_SCALE
    bx, by = barrel_end(t)                     # 포신 끝에서 출발
    shell = {
        "x": bx,
        "y": by,
        "vx": math.cos(rad) * speed,           # 가로 속도
        "vy": -math.sin(rad) * speed,          # 세로 속도 (위가 음수)
    }

running = True
while running:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
        if event.type == pygame.KEYDOWN and event.key == pygame.K_SPACE:
            fire(tanks[0])                     # 스페이스로 발사

    keys = pygame.key.get_pressed()
    me = tanks[0]
    if keys[pygame.K_LEFT]:
        me["angle"] = min(180.0, me["angle"] + 0.8)
    if keys[pygame.K_RIGHT]:
        me["angle"] = max(0.0, me["angle"] - 0.8)
    if keys[pygame.K_UP]:
        me["power"] = min(100.0, me["power"] + 0.6)
    if keys[pygame.K_DOWN]:
        me["power"] = max(5.0, me["power"] - 0.6)

    # 포탄을 속도만큼 움직인다 (벽돌깨기의 공과 똑같다)
    if shell:
        shell["x"] += shell["vx"]
        shell["y"] += shell["vy"]
        if shell["x"] < -300 or shell["x"] > W + 300 or shell["y"] > H + 600:
            shell = None                       # 화면 밖으로 나가면 사라짐

    screen.fill(SKY)
    points = [(0, H)] + [(x, ground[x]) for x in range(W)] + [(W, H)]
    pygame.draw.polygon(screen, GROUND_COLOR, points)

    for t in tanks:
        r = tank_rect(t)
        pygame.draw.rect(screen, t["color"], r, border_radius=6)
        bx, by = barrel_end(t)
        pygame.draw.line(screen, t["color"], (t["x"], r.top), (bx, by), 5)

    if shell:
        pygame.draw.circle(screen, (40, 40, 50), (int(shell["x"]), int(shell["y"])), SHELL_R)

    screen.blit(font.render(f"각도 {me['angle']:.0f}°", True, (25, 30, 40)), (16, 14))
    screen.blit(font.render(f"힘 {me['power']:.0f}", True, (25, 30, 40)), (16, 40))
    gauge = pygame.Rect(16, 68, 200, 14)
    pygame.draw.rect(screen, (230, 230, 230), gauge, border_radius=7)
    filled = pygame.Rect(16, 68, int(200 * me["power"] / 100), 14)
    pygame.draw.rect(screen, (240, 150, 60), filled, border_radius=7)

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

pygame.quit()

실행해서 스페이스를 눌러보세요. 포탄이 직선으로 쭉 날아갑니다. 아직 중력이 없어서 그렇습니다. 다음 챕터에서 한 줄만 더하면 포물선이 됩니다.

직접 해보기 각도를 0°, 45°, 90°, 135°로 바꿔가며 쏴보고, 포탄이 정말 그 방향으로 날아가는지 확인하세요. 힘을 10과 100으로 바꾸면 방향은 같은데 빠르기만 달라지는 것도 확인해보세요.

5중력과 포물선

벽돌깨기 8번 챕터에서 실험했던 바로 그 한 줄입니다.

shell["vy"] += GRAVITY

매 프레임 세로 속도에 조금씩 더하면, 포탄은 올라가다가 느려지고, 멈췄다가, 내려오면서 빨라집니다. 이것이 포물선입니다.

vy = −8.6 (빠르게 위로) vy = 0 (가장 높은 곳) vy = +8.6 (빠르게 아래로) vy가 계속 +0.2씩 더해진다

vx는 한 번도 안 바뀝니다. 가로로는 항상 같은 속도로 나아가고, 세로 속도만 중력 때문에 계속 변합니다. 그래서 좌우로는 일정하게, 위아래로는 곡선을 그리며 — 포물선이 완성됩니다.

import pygame
import math

pygame.init()
W, H = 900, 600
screen = pygame.display.set_mode((W, H))
pygame.display.set_caption("탱크 게임")
clock = pygame.time.Clock()
font = pygame.font.SysFont("malgungothic", 20)

SKY = (140, 185, 225)
GROUND_COLOR = (80, 120, 70)
TANK_W, TANK_H = 46, 20
BARREL_LEN = 42
POWER_SCALE = 0.22                             # 힘(0~100)을 실제 속도로
SHELL_R = 5
GRAVITY = 0.2                                  # 매 프레임 아래로 더해지는 속도

ground = []
for x in range(W):
    y = H - 150 - 38 * math.sin(x / 120) - 18 * math.sin(x / 43)
    ground.append(y)

def ground_y(x):
    xi = max(0, min(W - 1, int(x)))
    return ground[xi]

tanks = [
    {"x": 120.0, "angle": 45.0, "power": 55.0, "color": (55, 105, 200), "name": "내 탱크"},
    {"x": W - 120.0, "angle": 135.0, "power": 55.0, "color": (200, 80, 70), "name": "적 탱크"},
]
shell = None                                   # 날아가는 포탄 (없으면 None)

def tank_rect(t):
    y = ground_y(t["x"])
    return pygame.Rect(int(t["x"] - TANK_W / 2), int(y - TANK_H), TANK_W, TANK_H)

def barrel_end(t):
    rad = math.radians(t["angle"])
    bx = t["x"] + math.cos(rad) * BARREL_LEN
    by = (ground_y(t["x"]) - TANK_H) - math.sin(rad) * BARREL_LEN
    return bx, by

def fire(t):
    # 각도와 힘으로 포탄의 처음 속도를 만든다
    global shell
    rad = math.radians(t["angle"])
    speed = t["power"] * POWER_SCALE
    bx, by = barrel_end(t)                     # 포신 끝에서 출발
    shell = {
        "x": bx,
        "y": by,
        "vx": math.cos(rad) * speed,           # 가로 속도
        "vy": -math.sin(rad) * speed,          # 세로 속도 (위가 음수)
    }

running = True
while running:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
        if event.type == pygame.KEYDOWN and event.key == pygame.K_SPACE:
            fire(tanks[0])                     # 스페이스로 발사

    keys = pygame.key.get_pressed()
    me = tanks[0]
    if keys[pygame.K_LEFT]:
        me["angle"] = min(180.0, me["angle"] + 0.8)
    if keys[pygame.K_RIGHT]:
        me["angle"] = max(0.0, me["angle"] - 0.8)
    if keys[pygame.K_UP]:
        me["power"] = min(100.0, me["power"] + 0.6)
    if keys[pygame.K_DOWN]:
        me["power"] = max(5.0, me["power"] - 0.6)

    # 포탄을 속도만큼 움직인다 (벽돌깨기의 공과 똑같다)
    if shell:
        shell["vy"] += GRAVITY                 # 이 한 줄이 포물선을 만든다
        shell["x"] += shell["vx"]
        shell["y"] += shell["vy"]
        if shell["x"] < -300 or shell["x"] > W + 300 or shell["y"] > H + 600:
            shell = None                       # 화면 밖으로 나가면 사라짐

    screen.fill(SKY)
    points = [(0, H)] + [(x, ground[x]) for x in range(W)] + [(W, H)]
    pygame.draw.polygon(screen, GROUND_COLOR, points)

    for t in tanks:
        r = tank_rect(t)
        pygame.draw.rect(screen, t["color"], r, border_radius=6)
        bx, by = barrel_end(t)
        pygame.draw.line(screen, t["color"], (t["x"], r.top), (bx, by), 5)

    if shell:
        pygame.draw.circle(screen, (40, 40, 50), (int(shell["x"]), int(shell["y"])), SHELL_R)

    screen.blit(font.render(f"각도 {me[&#x27;angle']:.0f}°", True, (25, 30, 40)), (16, 14))
    screen.blit(font.render(f"힘 {me[&#x27;power']:.0f}", True, (25, 30, 40)), (16, 40))
    gauge = pygame.Rect(16, 68, 200, 14)
    pygame.draw.rect(screen, (230, 230, 230), gauge, border_radius=7)
    filled = pygame.Rect(16, 68, int(200 * me["power"] / 100), 14)
    pygame.draw.rect(screen, (240, 150, 60), filled, border_radius=7)

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

pygame.quit()

각도와 사거리의 관계 — 왜 45도가 가장 멀까?

같은 힘으로 쏠 때 각도에 따라 얼마나 날아가는지 직접 실험해보면 재미있는 사실을 발견합니다.

각도결과이유
15°가깝게 떨어짐앞으로는 빠른데 금방 땅에 닿음 (체공 시간 짧음)
45°가장 멀리앞으로 가는 속도와 떠 있는 시간의 균형이 가장 좋음
75°가깝게 떨어짐오래 떠 있지만 앞으로 거의 안 나감
90°제자리가로 속도가 0이라 쏜 자리로 다시 떨어짐
이것이 실제 포병들이 쓰는 원리입니다. 공기 저항이 없다면 45도에서 사거리가 최대가 된다는 건 수학으로 증명된 사실입니다. 또 한 가지 재미있는 점 — 30도와 60도는 사거리가 같습니다. 직접 쏴서 확인해보세요!
직접 해보기
  • 힘을 60으로 고정하고 각도만 15° / 30° / 45° / 60° / 75°로 바꿔 쏘면서, 어디에 떨어지는지 비교하세요. 30°와 60°가 정말 같은 곳에 떨어지나요?
  • GRAVITY를 0.05(달)와 0.6(목성)으로 바꿔보세요. 달에서는 포탄이 얼마나 멀리 날아가나요?

6땅에 맞으면 폭발

포탄이 땅을 뚫고 지나가면 안 되겠죠. 1번 챕터에서 땅을 리스트로 만들어둔 덕분에 충돌 판정이 아주 쉽습니다.

if shell["y"] >= ground_y(shell["x"]):
    # 포탄의 y가 그 자리 땅 높이보다 아래 = 땅에 닿았다

그리고 터지는 순간을 보여주기 위해 점점 커지는 원으로 폭발 효과를 만듭니다.

import pygame
import math

pygame.init()
W, H = 900, 600
screen = pygame.display.set_mode((W, H))
pygame.display.set_caption("탱크 게임")
clock = pygame.time.Clock()
font = pygame.font.SysFont("malgungothic", 20)

SKY = (140, 185, 225)
GROUND_COLOR = (80, 120, 70)
TANK_W, TANK_H = 46, 20
BARREL_LEN = 42
POWER_SCALE = 0.22                             # 힘(0~100)을 실제 속도로
SHELL_R = 5
GRAVITY = 0.2                                  # 매 프레임 아래로 더해지는 속도
BOOM_MAX = 40                                  # 폭발이 커지는 최대 크기

ground = []
for x in range(W):
    y = H - 150 - 38 * math.sin(x / 120) - 18 * math.sin(x / 43)
    ground.append(y)

def ground_y(x):
    xi = max(0, min(W - 1, int(x)))
    return ground[xi]

tanks = [
    {"x": 120.0, "angle": 45.0, "power": 55.0, "color": (55, 105, 200), "name": "내 탱크"},
    {"x": W - 120.0, "angle": 135.0, "power": 55.0, "color": (200, 80, 70), "name": "적 탱크"},
]
shell = None                                   # 날아가는 포탄 (없으면 None)
boom = None                                    # 폭발 효과 {"x","y","r"}

def tank_rect(t):
    y = ground_y(t["x"])
    return pygame.Rect(int(t["x"] - TANK_W / 2), int(y - TANK_H), TANK_W, TANK_H)

def barrel_end(t):
    rad = math.radians(t["angle"])
    bx = t["x"] + math.cos(rad) * BARREL_LEN
    by = (ground_y(t["x"]) - TANK_H) - math.sin(rad) * BARREL_LEN
    return bx, by

def explode(x, y):
    # 포탄을 없애고 그 자리에 폭발을 만든다
    global shell, boom
    shell = None
    boom = {"x": x, "y": y, "r": 6.0}

def fire(t):
    # 각도와 힘으로 포탄의 처음 속도를 만든다
    global shell
    rad = math.radians(t["angle"])
    speed = t["power"] * POWER_SCALE
    bx, by = barrel_end(t)                     # 포신 끝에서 출발
    shell = {
        "x": bx,
        "y": by,
        "vx": math.cos(rad) * speed,           # 가로 속도
        "vy": -math.sin(rad) * speed,          # 세로 속도 (위가 음수)
    }

running = True
while running:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
        if event.type == pygame.KEYDOWN and event.key == pygame.K_SPACE:
            fire(tanks[0])                     # 스페이스로 발사

    keys = pygame.key.get_pressed()
    me = tanks[0]
    if keys[pygame.K_LEFT]:
        me["angle"] = min(180.0, me["angle"] + 0.8)
    if keys[pygame.K_RIGHT]:
        me["angle"] = max(0.0, me["angle"] - 0.8)
    if keys[pygame.K_UP]:
        me["power"] = min(100.0, me["power"] + 0.6)
    if keys[pygame.K_DOWN]:
        me["power"] = max(5.0, me["power"] - 0.6)

    # 포탄을 속도만큼 움직인다 (벽돌깨기의 공과 똑같다)
    if shell:
        shell["vy"] += GRAVITY                 # 이 한 줄이 포물선을 만든다
        shell["x"] += shell["vx"]
        shell["y"] += shell["vy"]

        if shell["y"] >= ground_y(shell["x"]):      # 땅에 닿았으면 폭발
            explode(shell["x"], ground_y(shell["x"]))
        elif shell["x"] < -300 or shell["x"] > W + 300 or shell["y"] > H + 600:
            shell = None                       # 화면 밖으로 나가면 사라짐

    # 폭발이 점점 커지다가 사라진다
    if boom:
        boom["r"] += 2.5
        if boom["r"] >= BOOM_MAX:
            boom = None

    screen.fill(SKY)
    points = [(0, H)] + [(x, ground[x]) for x in range(W)] + [(W, H)]
    pygame.draw.polygon(screen, GROUND_COLOR, points)

    for t in tanks:
        r = tank_rect(t)
        pygame.draw.rect(screen, t["color"], r, border_radius=6)
        bx, by = barrel_end(t)
        pygame.draw.line(screen, t["color"], (t["x"], r.top), (bx, by), 5)

    if shell:
        pygame.draw.circle(screen, (40, 40, 50), (int(shell["x"]), int(shell["y"])), SHELL_R)

    if boom:
        pygame.draw.circle(screen, (255, 190, 60), (int(boom["x"]), int(boom["y"])), int(boom["r"]))
        pygame.draw.circle(screen, (255, 120, 50), (int(boom["x"]), int(boom["y"])), int(boom["r"] * 0.6))

    screen.blit(font.render(f"각도 {me[&#x27;angle']:.0f}°", True, (25, 30, 40)), (16, 14))
    screen.blit(font.render(f"힘 {me[&#x27;power']:.0f}", True, (25, 30, 40)), (16, 40))
    gauge = pygame.Rect(16, 68, 200, 14)
    pygame.draw.rect(screen, (230, 230, 230), gauge, border_radius=7)
    filled = pygame.Rect(16, 68, int(200 * me["power"] / 100), 14)
    pygame.draw.rect(screen, (240, 150, 60), filled, border_radius=7)

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

pygame.quit()
폭발 효과 만드는 요령: 큰 노란 원을 그리고 그 위에 조금 더 작은 주황 원을 겹쳐 그리면 불꽃처럼 보입니다. 반지름을 매 프레임 2.5씩 키우다가 40이 되면 없애는 것뿐인데, 눈에는 제법 그럴듯하게 보입니다.
순서에 주의! ifelif로 이어야 합니다. 땅에 닿은 프레임에 화면 밖 조건까지 검사해버리면, 이미 None이 된 shell을 다시 건드려 오류가 납니다.
직접 해보기 폭발이 사라질 때 갑자기 없어지지 않고 색이 옅어지며 사라지게 만들어보세요. (힌트: boom["r"]이 커질수록 색의 초록·파랑 값을 함께 키우면 흰색에 가까워집니다)

7명중 판정과 체력

2주차 시작입니다. 이제 맞히면 아프게 만듭니다. 탱크에 hp(체력)를 주고, 폭발 지점에서 가까울수록 큰 피해를 입도록 합니다.

거리에 따라 피해를 다르게 — math.hypot

두 점 사이의 거리는 피타고라스 정리로 구합니다. 파이썬에는 math.hypot()이 준비되어 있어서 한 줄이면 됩니다.

d = math.hypot(탱크x - 폭발x, 탱크y - 폭발y)
# 아래와 똑같지만 훨씬 짧고 정확하다
d = ((탱크x - 폭발x) ** 2 + (탱크y - 폭발y) ** 2) ** 0.5
폭발 지점 가까움 = 큰 피해 멀수록 작은 피해 반지름 70 밖 = 피해 없음 d

1 - d / 70거리가 0이면 1, 거리가 70이면 0이 되는 비율입니다. 벽돌깨기에서 offset을 −1~1로 만들었던 것과 같은 정규화 기법입니다.

import pygame
import math

pygame.init()
W, H = 900, 600
screen = pygame.display.set_mode((W, H))
pygame.display.set_caption("탱크 게임")
clock = pygame.time.Clock()
font = pygame.font.SysFont("malgungothic", 20)

SKY = (140, 185, 225)
GROUND_COLOR = (80, 120, 70)
TANK_W, TANK_H = 46, 20
BARREL_LEN = 42
POWER_SCALE = 0.22                             # 힘(0~100)을 실제 속도로
SHELL_R = 5
GRAVITY = 0.2                                  # 매 프레임 아래로 더해지는 속도
BOOM_MAX = 40                                  # 폭발이 커지는 최대 크기

ground = []
for x in range(W):
    y = H - 150 - 38 * math.sin(x / 120) - 18 * math.sin(x / 43)
    ground.append(y)

def ground_y(x):
    xi = max(0, min(W - 1, int(x)))
    return ground[xi]

tanks = [
    {"x": 120.0, "angle": 45.0, "power": 55.0, "hp": 100,
     "color": (55, 105, 200), "name": "내 탱크"},
    {"x": W - 120.0, "angle": 135.0, "power": 55.0, "hp": 100,
     "color": (200, 80, 70), "name": "적 탱크"},
]
shell = None                                   # 날아가는 포탄 (없으면 None)
boom = None                                    # 폭발 효과 {"x","y","r"}

def tank_rect(t):
    y = ground_y(t["x"])
    return pygame.Rect(int(t["x"] - TANK_W / 2), int(y - TANK_H), TANK_W, TANK_H)

def barrel_end(t):
    rad = math.radians(t["angle"])
    bx = t["x"] + math.cos(rad) * BARREL_LEN
    by = (ground_y(t["x"]) - TANK_H) - math.sin(rad) * BARREL_LEN
    return bx, by

def explode(x, y):
    # 포탄을 없애고 그 자리에 폭발을 만든다
    global shell, boom
    shell = None
    boom = {"x": x, "y": y, "r": 6.0}
    # 폭발 지점에서 가까운 탱크에 피해를 준다
    for t in tanks:
        cx, cy = t["x"], ground_y(t["x"]) - TANK_H / 2
        d = math.hypot(cx - x, cy - y)
        if d < 70:
            t["hp"] -= int(50 * (1 - d / 70)) + 8   # 가까울수록 큰 피해
            t["hp"] = max(0, t["hp"])

def fire(t):
    # 각도와 힘으로 포탄의 처음 속도를 만든다
    global shell
    rad = math.radians(t["angle"])
    speed = t["power"] * POWER_SCALE
    bx, by = barrel_end(t)                     # 포신 끝에서 출발
    shell = {
        "x": bx,
        "y": by,
        "vx": math.cos(rad) * speed,           # 가로 속도
        "vy": -math.sin(rad) * speed,          # 세로 속도 (위가 음수)
    }

running = True
while running:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
        if event.type == pygame.KEYDOWN and event.key == pygame.K_SPACE:
            fire(tanks[0])                     # 스페이스로 발사

    keys = pygame.key.get_pressed()
    me = tanks[0]
    if keys[pygame.K_LEFT]:
        me["angle"] = min(180.0, me["angle"] + 0.8)
    if keys[pygame.K_RIGHT]:
        me["angle"] = max(0.0, me["angle"] - 0.8)
    if keys[pygame.K_UP]:
        me["power"] = min(100.0, me["power"] + 0.6)
    if keys[pygame.K_DOWN]:
        me["power"] = max(5.0, me["power"] - 0.6)

    # 포탄을 속도만큼 움직인다 (벽돌깨기의 공과 똑같다)
    if shell:
        shell["vy"] += GRAVITY                 # 이 한 줄이 포물선을 만든다
        shell["x"] += shell["vx"]
        shell["y"] += shell["vy"]

        # 탱크에 직접 맞았는지 먼저 확인한다
        hit_tank = None
        for t in tanks:
            if tank_rect(t).collidepoint(shell["x"], shell["y"]):
                hit_tank = t

        if hit_tank:
            explode(shell["x"], shell["y"])
        elif shell["y"] >= ground_y(shell["x"]):    # 땅에 닿았으면 폭발
            explode(shell["x"], ground_y(shell["x"]))
        elif shell["x"] < -300 or shell["x"] > W + 300 or shell["y"] > H + 600:
            shell = None                       # 화면 밖으로 나가면 사라짐

    # 폭발이 점점 커지다가 사라진다
    if boom:
        boom["r"] += 2.5
        if boom["r"] >= BOOM_MAX:
            boom = None

    screen.fill(SKY)
    points = [(0, H)] + [(x, ground[x]) for x in range(W)] + [(W, H)]
    pygame.draw.polygon(screen, GROUND_COLOR, points)

    for t in tanks:
        r = tank_rect(t)
        pygame.draw.rect(screen, t["color"], r, border_radius=6)
        bx, by = barrel_end(t)
        pygame.draw.line(screen, t["color"], (t["x"], r.top), (bx, by), 5)

        # 체력바: 회색 바탕 위에 체력만큼 채운다
        bar = pygame.Rect(r.centerx - 25, r.top - 16, 50, 7)
        pygame.draw.rect(screen, (220, 220, 220), bar, border_radius=3)
        hp_w = int(50 * t["hp"] / 100)
        pygame.draw.rect(screen, (90, 190, 100) if t["hp"] > 30 else (210, 80, 70),
                         pygame.Rect(bar.x, bar.y, hp_w, bar.h), border_radius=3)

    if shell:
        pygame.draw.circle(screen, (40, 40, 50), (int(shell["x"]), int(shell["y"])), SHELL_R)

    if boom:
        pygame.draw.circle(screen, (255, 190, 60), (int(boom["x"]), int(boom["y"])), int(boom["r"]))
        pygame.draw.circle(screen, (255, 120, 50), (int(boom["x"]), int(boom["y"])), int(boom["r"] * 0.6))

    screen.blit(font.render(f"각도 {me[&#x27;angle']:.0f}°", True, (25, 30, 40)), (16, 14))
    screen.blit(font.render(f"힘 {me[&#x27;power']:.0f}", True, (25, 30, 40)), (16, 40))
    gauge = pygame.Rect(16, 68, 200, 14)
    pygame.draw.rect(screen, (230, 230, 230), gauge, border_radius=7)
    filled = pygame.Rect(16, 68, int(200 * me["power"] / 100), 14)
    pygame.draw.rect(screen, (240, 150, 60), filled, border_radius=7)

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

pygame.quit()
탱크 판정을 땅 판정보다 먼저 하는 이유: 탱크는 땅 위에 서 있습니다. 땅 검사를 먼저 하면, 탱크 몸통에 맞았는데도 "땅에 닿았다"로 처리되어 직격탄이 무시될 수 있습니다.
직접 해보기 피해 범위 70150으로 키워보세요. 훨씬 쉬워지죠? 반대로 30으로 줄이면 정확히 맞혀야만 피해를 줄 수 있습니다. 게임의 난이도가 숫자 하나로 확 바뀌는 걸 느껴보세요.

8턴 방식으로 번갈아 쏘기

지금은 내가 무한정 쏠 수 있습니다. 이제 내 차례 → 적 차례 → 내 차례…로 번갈아 가도록 만듭니다. 이를 위해 두 개의 변수가 필요합니다.

aim 조준 중 발사 fly 포탄 비행 중 명중/착탄 boom 폭발 중 끝나면 다음 차례

이렇게 상태가 순서대로 흘러가는 구조를 상태 기계라고 부릅니다. 피하기 게임과 벽돌깨기에서 "play" / "over"로 쓰던 것을 조금 더 확장한 것뿐입니다.

import pygame
import math
import random

pygame.init()
W, H = 900, 600
screen = pygame.display.set_mode((W, H))
pygame.display.set_caption("탱크 게임")
clock = pygame.time.Clock()
font = pygame.font.SysFont("malgungothic", 20)

SKY = (140, 185, 225)
GROUND_COLOR = (80, 120, 70)
TANK_W, TANK_H = 46, 20
BARREL_LEN = 42
POWER_SCALE = 0.22                             # 힘(0~100)을 실제 속도로
SHELL_R = 5
GRAVITY = 0.2                                  # 매 프레임 아래로 더해지는 속도
BOOM_MAX = 40                                  # 폭발이 커지는 최대 크기

ground = []
for x in range(W):
    y = H - 150 - 38 * math.sin(x / 120) - 18 * math.sin(x / 43)
    ground.append(y)

def ground_y(x):
    xi = max(0, min(W - 1, int(x)))
    return ground[xi]

tanks = [
    {"x": 120.0, "angle": 45.0, "power": 55.0, "hp": 100,
     "color": (55, 105, 200), "name": "내 탱크"},
    {"x": W - 120.0, "angle": 135.0, "power": 55.0, "hp": 100,
     "color": (200, 80, 70), "name": "적 탱크"},
]
shell = None                                   # 날아가는 포탄 (없으면 None)
boom = None                                    # 폭발 효과 {"x","y","r"}
turn = 0                                       # 0 = 내 탱크, 1 = 적 탱크
state = "aim"                                  # "aim" / "fly" / "boom"

def tank_rect(t):
    y = ground_y(t["x"])
    return pygame.Rect(int(t["x"] - TANK_W / 2), int(y - TANK_H), TANK_W, TANK_H)

def barrel_end(t):
    rad = math.radians(t["angle"])
    bx = t["x"] + math.cos(rad) * BARREL_LEN
    by = (ground_y(t["x"]) - TANK_H) - math.sin(rad) * BARREL_LEN
    return bx, by

def explode(x, y):
    # 포탄을 없애고 그 자리에 폭발을 만든다
    global shell, boom, state
    shell = None
    boom = {"x": x, "y": y, "r": 6.0}
    # 폭발 지점에서 가까운 탱크에 피해를 준다
    for t in tanks:
        cx, cy = t["x"], ground_y(t["x"]) - TANK_H / 2
        d = math.hypot(cx - x, cy - y)
        if d < 70:
            t["hp"] -= int(50 * (1 - d / 70)) + 8   # 가까울수록 큰 피해
            t["hp"] = max(0, t["hp"])
    state = "boom"                             # 폭발 단계로

def next_turn():
    # 폭발이 끝나면 차례를 넘긴다
    global turn, state
    turn = 1 - turn                            # 0↔1 뒤집기
    state = "aim"
    if turn == 1:
        enemy_turn()

def enemy_turn():
    # 임시 버전: 대충 아무렇게나 쏜다 (9번 챕터에서 똑똑하게 만든다)
    t = tanks[1]
    t["angle"] = random.uniform(120, 150)
    t["power"] = random.uniform(45, 70)
    fire(t)

def fire(t):
    # 각도와 힘으로 포탄의 처음 속도를 만든다
    global shell, state
    rad = math.radians(t["angle"])
    speed = t["power"] * POWER_SCALE
    bx, by = barrel_end(t)                     # 포신 끝에서 출발
    shell = {
        "x": bx,
        "y": by,
        "vx": math.cos(rad) * speed,           # 가로 속도
        "vy": -math.sin(rad) * speed,          # 세로 속도 (위가 음수)
    }
    state = "fly"                              # 쏘면 비행 단계로

running = True
while running:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
        if event.type == pygame.KEYDOWN and event.key == pygame.K_SPACE:
            if state == "aim" and turn == 0:   # 내 차례, 조준 중일 때만 발사
                fire(tanks[0])

    # 내 차례에 조준 중일 때만 각도·힘을 바꿀 수 있다
    me = tanks[0]
    if state == "aim" and turn == 0:
        keys = pygame.key.get_pressed()
        if keys[pygame.K_LEFT]:
            me["angle"] = min(180.0, me["angle"] + 0.8)
        if keys[pygame.K_RIGHT]:
            me["angle"] = max(0.0, me["angle"] - 0.8)
        if keys[pygame.K_UP]:
            me["power"] = min(100.0, me["power"] + 0.6)
        if keys[pygame.K_DOWN]:
            me["power"] = max(5.0, me["power"] - 0.6)

    # 포탄을 속도만큼 움직인다 (벽돌깨기의 공과 똑같다)
    if state == "fly" and shell:
        shell["vy"] += GRAVITY                 # 이 한 줄이 포물선을 만든다
        shell["x"] += shell["vx"]
        shell["y"] += shell["vy"]

        # 탱크에 직접 맞았는지 먼저 확인한다
        hit_tank = None
        for t in tanks:
            if tank_rect(t).collidepoint(shell["x"], shell["y"]):
                hit_tank = t

        if hit_tank:
            explode(shell["x"], shell["y"])
        elif shell["y"] >= ground_y(shell["x"]):    # 땅에 닿았으면 폭발
            explode(shell["x"], ground_y(shell["x"]))
        elif shell["x"] < -300 or shell["x"] > W + 300 or shell["y"] > H + 600:
            shell = None                       # 화면 밖으로 나가면 사라짐

    # 폭발이 점점 커지다가 사라진다
    if state == "boom" and boom:
        boom["r"] += 2.5
        if boom["r"] >= BOOM_MAX:
            boom = None
            next_turn()                        # 폭발이 끝나야 다음 차례

    screen.fill(SKY)
    points = [(0, H)] + [(x, ground[x]) for x in range(W)] + [(W, H)]
    pygame.draw.polygon(screen, GROUND_COLOR, points)

    for t in tanks:
        r = tank_rect(t)
        pygame.draw.rect(screen, t["color"], r, border_radius=6)
        bx, by = barrel_end(t)
        pygame.draw.line(screen, t["color"], (t["x"], r.top), (bx, by), 5)

        # 체력바: 회색 바탕 위에 체력만큼 채운다
        bar = pygame.Rect(r.centerx - 25, r.top - 16, 50, 7)
        pygame.draw.rect(screen, (220, 220, 220), bar, border_radius=3)
        hp_w = int(50 * t["hp"] / 100)
        pygame.draw.rect(screen, (90, 190, 100) if t["hp"] > 30 else (210, 80, 70),
                         pygame.Rect(bar.x, bar.y, hp_w, bar.h), border_radius=3)

    if shell:
        pygame.draw.circle(screen, (40, 40, 50), (int(shell["x"]), int(shell["y"])), SHELL_R)

    if boom:
        pygame.draw.circle(screen, (255, 190, 60), (int(boom["x"]), int(boom["y"])), int(boom["r"]))
        pygame.draw.circle(screen, (255, 120, 50), (int(boom["x"]), int(boom["y"])), int(boom["r"] * 0.6))

    screen.blit(font.render(f"각도 {me[&#x27;angle']:.0f}°", True, (25, 30, 40)), (16, 14))
    screen.blit(font.render(f"힘 {me[&#x27;power']:.0f}", True, (25, 30, 40)), (16, 40))
    gauge = pygame.Rect(16, 68, 200, 14)
    pygame.draw.rect(screen, (230, 230, 230), gauge, border_radius=7)
    filled = pygame.Rect(16, 68, int(200 * me["power"] / 100), 14)
    pygame.draw.rect(screen, (240, 150, 60), filled, border_radius=7)

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

pygame.quit()
turn = 1 - turn0과 1을 번갈아 뒤집는 방법입니다. 0이면 1−0=1, 1이면 1−1=0이 되죠. if문을 쓰지 않고 한 줄로 해결하는 흔한 요령입니다.
코드 맨 위에 import random을 추가해야 random.uniform이 동작합니다.
직접 해보기 적이 쏘는 동안 화면에 "적 차례..."라고 표시해보세요. 상태와 차례를 화면에 보여주면 디버깅이 훨씬 쉬워집니다.

9적 AI — 점점 정확해지는 적

지금 적은 완전히 무작위로 쏴서 전혀 위협적이지 않습니다. 진짜 사람처럼 빗나간 만큼 보고 다음 발을 조정하게 만들어봅시다. 실제 포병이 "초탄 관측 후 수정사격"을 하는 방식과 같습니다.

내 탱크 적 탱크 1발째 오차 +123 너무 멀리 감 → 힘을 줄이자 2발째 오차를 보고 힘을 조절한다: 새 힘 = 지금 힘 + 오차 × 0.06

적은 오른쪽에 있고 왼쪽으로 쏩니다. 포탄이 내 탱크보다 오른쪽에 떨어졌다면(오차가 양수) 덜 날아간 것이니 힘을 키워야 합니다. 반대면 줄입니다. 그래서 오차에 비례해 힘을 조정하면 됩니다.

import pygame
import math
import random

pygame.init()
W, H = 900, 600
screen = pygame.display.set_mode((W, H))
pygame.display.set_caption("탱크 게임")
clock = pygame.time.Clock()
font = pygame.font.SysFont("malgungothic", 20)

SKY = (140, 185, 225)
GROUND_COLOR = (80, 120, 70)
TANK_W, TANK_H = 46, 20
BARREL_LEN = 42
POWER_SCALE = 0.22                             # 힘(0~100)을 실제 속도로
SHELL_R = 5
GRAVITY = 0.2                                  # 매 프레임 아래로 더해지는 속도
BOOM_MAX = 40                                  # 폭발이 커지는 최대 크기

ground = []
for x in range(W):
    y = H - 150 - 38 * math.sin(x / 120) - 18 * math.sin(x / 43)
    ground.append(y)

def ground_y(x):
    xi = max(0, min(W - 1, int(x)))
    return ground[xi]

tanks = [
    {"x": 120.0, "angle": 45.0, "power": 55.0, "hp": 100,
     "color": (55, 105, 200), "name": "내 탱크"},
    {"x": W - 120.0, "angle": 135.0, "power": 55.0, "hp": 100,
     "color": (200, 80, 70), "name": "적 탱크"},
]
shell = None                                   # 날아가는 포탄 (없으면 None)
boom = None                                    # 폭발 효과 {"x","y","r"}
turn = 0                                       # 0 = 내 탱크, 1 = 적 탱크
state = "aim"                                  # "aim" / "fly" / "boom"
enemy_miss = None                              # 적이 지난번 얼마나 빗나갔는지

def tank_rect(t):
    y = ground_y(t["x"])
    return pygame.Rect(int(t["x"] - TANK_W / 2), int(y - TANK_H), TANK_W, TANK_H)

def barrel_end(t):
    rad = math.radians(t["angle"])
    bx = t["x"] + math.cos(rad) * BARREL_LEN
    by = (ground_y(t["x"]) - TANK_H) - math.sin(rad) * BARREL_LEN
    return bx, by

def explode(x, y):
    # 포탄을 없애고 그 자리에 폭발을 만든다
    global shell, boom, state, enemy_miss
    shell = None
    boom = {"x": x, "y": y, "r": 6.0}
    # 폭발 지점에서 가까운 탱크에 피해를 준다
    for t in tanks:
        cx, cy = t["x"], ground_y(t["x"]) - TANK_H / 2
        d = math.hypot(cx - x, cy - y)
        if d < 70:
            t["hp"] -= int(50 * (1 - d / 70)) + 8   # 가까울수록 큰 피해
            t["hp"] = max(0, t["hp"])
    if turn == 1:                              # 적이 쏜 포탄이었다면
        enemy_miss = x - tanks[0]["x"]         # 내 탱크와 얼마나 떨어졌나 기억
    state = "boom"                             # 폭발 단계로

def next_turn():
    # 폭발이 끝나면 차례를 넘긴다
    global turn, state
    turn = 1 - turn                            # 0↔1 뒤집기
    state = "aim"
    if turn == 1:
        enemy_turn()

def enemy_turn():
    # 지난번 빗나간 거리를 보고 힘을 조절한다
    t = tanks[1]
    t["angle"] = random.uniform(120, 150)
    if enemy_miss is None:
        t["power"] = random.uniform(45, 70)    # 첫 발은 감으로 쏜다
    else:
        # 빗나간 만큼 힘을 조정한다 (20~95 사이로 제한)
        t["power"] = max(20.0, min(95.0, t["power"] + enemy_miss * 0.06))
    fire(t)

def fire(t):
    # 각도와 힘으로 포탄의 처음 속도를 만든다
    global shell, state
    rad = math.radians(t["angle"])
    speed = t["power"] * POWER_SCALE
    bx, by = barrel_end(t)                     # 포신 끝에서 출발
    shell = {
        "x": bx,
        "y": by,
        "vx": math.cos(rad) * speed,           # 가로 속도
        "vy": -math.sin(rad) * speed,          # 세로 속도 (위가 음수)
    }
    state = "fly"                              # 쏘면 비행 단계로

running = True
while running:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
        if event.type == pygame.KEYDOWN and event.key == pygame.K_SPACE:
            if state == "aim" and turn == 0:   # 내 차례, 조준 중일 때만 발사
                fire(tanks[0])

    # 내 차례에 조준 중일 때만 각도·힘을 바꿀 수 있다
    me = tanks[0]
    if state == "aim" and turn == 0:
        keys = pygame.key.get_pressed()
        if keys[pygame.K_LEFT]:
            me["angle"] = min(180.0, me["angle"] + 0.8)
        if keys[pygame.K_RIGHT]:
            me["angle"] = max(0.0, me["angle"] - 0.8)
        if keys[pygame.K_UP]:
            me["power"] = min(100.0, me["power"] + 0.6)
        if keys[pygame.K_DOWN]:
            me["power"] = max(5.0, me["power"] - 0.6)

    # 포탄을 속도만큼 움직인다 (벽돌깨기의 공과 똑같다)
    if state == "fly" and shell:
        shell["vy"] += GRAVITY                 # 이 한 줄이 포물선을 만든다
        shell["x"] += shell["vx"]
        shell["y"] += shell["vy"]

        # 탱크에 직접 맞았는지 먼저 확인한다
        hit_tank = None
        for t in tanks:
            if tank_rect(t).collidepoint(shell["x"], shell["y"]):
                hit_tank = t

        if hit_tank:
            explode(shell["x"], shell["y"])
        elif shell["y"] >= ground_y(shell["x"]):    # 땅에 닿았으면 폭발
            explode(shell["x"], ground_y(shell["x"]))
        elif shell["x"] < -300 or shell["x"] > W + 300 or shell["y"] > H + 600:
            shell = None                       # 화면 밖으로 나가면 사라짐

    # 폭발이 점점 커지다가 사라진다
    if state == "boom" and boom:
        boom["r"] += 2.5
        if boom["r"] >= BOOM_MAX:
            boom = None
            next_turn()                        # 폭발이 끝나야 다음 차례

    screen.fill(SKY)
    points = [(0, H)] + [(x, ground[x]) for x in range(W)] + [(W, H)]
    pygame.draw.polygon(screen, GROUND_COLOR, points)

    for t in tanks:
        r = tank_rect(t)
        pygame.draw.rect(screen, t["color"], r, border_radius=6)
        bx, by = barrel_end(t)
        pygame.draw.line(screen, t["color"], (t["x"], r.top), (bx, by), 5)

        # 체력바: 회색 바탕 위에 체력만큼 채운다
        bar = pygame.Rect(r.centerx - 25, r.top - 16, 50, 7)
        pygame.draw.rect(screen, (220, 220, 220), bar, border_radius=3)
        hp_w = int(50 * t["hp"] / 100)
        pygame.draw.rect(screen, (90, 190, 100) if t["hp"] > 30 else (210, 80, 70),
                         pygame.Rect(bar.x, bar.y, hp_w, bar.h), border_radius=3)

    if shell:
        pygame.draw.circle(screen, (40, 40, 50), (int(shell["x"]), int(shell["y"])), SHELL_R)

    if boom:
        pygame.draw.circle(screen, (255, 190, 60), (int(boom["x"]), int(boom["y"])), int(boom["r"]))
        pygame.draw.circle(screen, (255, 120, 50), (int(boom["x"]), int(boom["y"])), int(boom["r"] * 0.6))

    screen.blit(font.render(f"각도 {me[&#x27;angle']:.0f}°", True, (25, 30, 40)), (16, 14))
    screen.blit(font.render(f"힘 {me[&#x27;power']:.0f}", True, (25, 30, 40)), (16, 40))
    gauge = pygame.Rect(16, 68, 200, 14)
    pygame.draw.rect(screen, (230, 230, 230), gauge, border_radius=7)
    filled = pygame.Rect(16, 68, int(200 * me["power"] / 100), 14)
    pygame.draw.rect(screen, (240, 150, 60), filled, border_radius=7)

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

pygame.quit()

0.06이라는 숫자의 의미

이 숫자가 적의 똑똑함을 결정합니다. 이런 방식을 "오차에 비례해 조정한다"고 해서 비례 제어라고 부르는데, 로봇이나 드론이 목표를 따라갈 때도 쓰는 실제 기법입니다.

적의 성격결과
0.01아주 조심스러움천천히 맞혀옴 — 쉬움
0.06적당함3~5발이면 근처에 맞힘
0.3성급함너무 많이 고쳐서 좌우로 왔다갔다 (수렴 못 함)
너무 크게 고치면 오히려 매번 지나쳐서 영원히 못 맞히는 현상이 생깁니다. 목욕물 온도를 맞출 때 수도꼭지를 확 돌리면 뜨거웠다 차가웠다 반복하는 것과 똑같습니다. "조금씩 고치기"가 핵심입니다.
직접 해보기
  • 0.060.3으로 바꾸고 적이 어떻게 헤매는지 관찰하세요.
  • 난이도 선택을 만들어보세요 — 쉬움 0.02 / 보통 0.06 / 어려움 0.12
  • 적이 힘만 조정하고 각도는 매번 무작위입니다. 각도도 고정해두면 더 정확해질까요? 실험해보세요.

10바람 넣기 — 한 줄로 난이도가 확 오른다

바람은 가로 속도를 매 프레임 조금씩 밀어주는 것입니다. 중력이 세로 속도에 GRAVITY를 더하는 것과 완전히 같은 원리입니다.

shell["vy"] += GRAVITY    # 중력: 세로로 계속 끌어내림
shell["vx"] += wind       # 바람: 가로로 계속 밀어줌  ← 새로 추가
바람 없을 때 순풍 → 더 멀리 역풍 ← 덜 감 바람 방향

바람은 차례가 넘어갈 때마다 새로 뽑습니다. 그래야 매번 다시 계산해야 해서 긴장감이 생깁니다.

import pygame
import math
import random

pygame.init()
W, H = 900, 600
screen = pygame.display.set_mode((W, H))
pygame.display.set_caption("탱크 게임")
clock = pygame.time.Clock()
font = pygame.font.SysFont("malgungothic", 20)

SKY = (140, 185, 225)
GROUND_COLOR = (80, 120, 70)
TANK_W, TANK_H = 46, 20
BARREL_LEN = 42
POWER_SCALE = 0.22                             # 힘(0~100)을 실제 속도로
SHELL_R = 5
GRAVITY = 0.2                                  # 매 프레임 아래로 더해지는 속도
BOOM_MAX = 40                                  # 폭발이 커지는 최대 크기

ground = []
for x in range(W):
    y = H - 150 - 38 * math.sin(x / 120) - 18 * math.sin(x / 43)
    ground.append(y)

def ground_y(x):
    xi = max(0, min(W - 1, int(x)))
    return ground[xi]

tanks = [
    {"x": 120.0, "angle": 45.0, "power": 55.0, "hp": 100,
     "color": (55, 105, 200), "name": "내 탱크"},
    {"x": W - 120.0, "angle": 135.0, "power": 55.0, "hp": 100,
     "color": (200, 80, 70), "name": "적 탱크"},
]
shell = None                                   # 날아가는 포탄 (없으면 None)
boom = None                                    # 폭발 효과 {"x","y","r"}
turn = 0                                       # 0 = 내 탱크, 1 = 적 탱크
state = "aim"                                  # "aim" / "fly" / "boom"
enemy_miss = None                              # 적이 지난번 얼마나 빗나갔는지
wind = 0.0                                     # 바람 세기 (양수면 오른쪽)

def tank_rect(t):
    y = ground_y(t["x"])
    return pygame.Rect(int(t["x"] - TANK_W / 2), int(y - TANK_H), TANK_W, TANK_H)

def barrel_end(t):
    rad = math.radians(t["angle"])
    bx = t["x"] + math.cos(rad) * BARREL_LEN
    by = (ground_y(t["x"]) - TANK_H) - math.sin(rad) * BARREL_LEN
    return bx, by

def explode(x, y):
    # 포탄을 없애고 그 자리에 폭발을 만든다
    global shell, boom, state, enemy_miss
    shell = None
    boom = {"x": x, "y": y, "r": 6.0}
    # 폭발 지점에서 가까운 탱크에 피해를 준다
    for t in tanks:
        cx, cy = t["x"], ground_y(t["x"]) - TANK_H / 2
        d = math.hypot(cx - x, cy - y)
        if d < 70:
            t["hp"] -= int(50 * (1 - d / 70)) + 8   # 가까울수록 큰 피해
            t["hp"] = max(0, t["hp"])
    if turn == 1:                              # 적이 쏜 포탄이었다면
        enemy_miss = x - tanks[0]["x"]         # 내 탱크와 얼마나 떨어졌나 기억
    state = "boom"                             # 폭발 단계로

def next_turn():
    # 폭발이 끝나면 차례를 넘긴다
    global turn, state, wind
    turn = 1 - turn                            # 0↔1 뒤집기
    wind = random.uniform(-0.05, 0.05)         # 차례마다 바람이 바뀐다
    state = "aim"
    if turn == 1:
        enemy_turn()

def enemy_turn():
    # 지난번 빗나간 거리를 보고 힘을 조절한다
    t = tanks[1]
    t["angle"] = random.uniform(120, 150)
    if enemy_miss is None:
        t["power"] = random.uniform(45, 70)    # 첫 발은 감으로 쏜다
    else:
        # 빗나간 만큼 힘을 조정한다 (20~95 사이로 제한)
        t["power"] = max(20.0, min(95.0, t["power"] + enemy_miss * 0.06))
    fire(t)

def fire(t):
    # 각도와 힘으로 포탄의 처음 속도를 만든다
    global shell, state
    rad = math.radians(t["angle"])
    speed = t["power"] * POWER_SCALE
    bx, by = barrel_end(t)                     # 포신 끝에서 출발
    shell = {
        "x": bx,
        "y": by,
        "vx": math.cos(rad) * speed,           # 가로 속도
        "vy": -math.sin(rad) * speed,          # 세로 속도 (위가 음수)
    }
    state = "fly"                              # 쏘면 비행 단계로

running = True
while running:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
        if event.type == pygame.KEYDOWN and event.key == pygame.K_SPACE:
            if state == "aim" and turn == 0:   # 내 차례, 조준 중일 때만 발사
                fire(tanks[0])

    # 내 차례에 조준 중일 때만 각도·힘을 바꿀 수 있다
    me = tanks[0]
    if state == "aim" and turn == 0:
        keys = pygame.key.get_pressed()
        if keys[pygame.K_LEFT]:
            me["angle"] = min(180.0, me["angle"] + 0.8)
        if keys[pygame.K_RIGHT]:
            me["angle"] = max(0.0, me["angle"] - 0.8)
        if keys[pygame.K_UP]:
            me["power"] = min(100.0, me["power"] + 0.6)
        if keys[pygame.K_DOWN]:
            me["power"] = max(5.0, me["power"] - 0.6)

    # 포탄을 속도만큼 움직인다 (벽돌깨기의 공과 똑같다)
    if state == "fly" and shell:
        shell["vx"] += wind                    # 바람이 가로로 밀어준다
        shell["vy"] += GRAVITY                 # 이 한 줄이 포물선을 만든다
        shell["x"] += shell["vx"]
        shell["y"] += shell["vy"]

        # 탱크에 직접 맞았는지 먼저 확인한다
        hit_tank = None
        for t in tanks:
            if tank_rect(t).collidepoint(shell["x"], shell["y"]):
                hit_tank = t

        if hit_tank:
            explode(shell["x"], shell["y"])
        elif shell["y"] >= ground_y(shell["x"]):    # 땅에 닿았으면 폭발
            explode(shell["x"], ground_y(shell["x"]))
        elif shell["x"] < -300 or shell["x"] > W + 300 or shell["y"] > H + 600:
            shell = None                       # 화면 밖으로 나가면 사라짐

    # 폭발이 점점 커지다가 사라진다
    if state == "boom" and boom:
        boom["r"] += 2.5
        if boom["r"] >= BOOM_MAX:
            boom = None
            next_turn()                        # 폭발이 끝나야 다음 차례

    screen.fill(SKY)
    points = [(0, H)] + [(x, ground[x]) for x in range(W)] + [(W, H)]
    pygame.draw.polygon(screen, GROUND_COLOR, points)

    for t in tanks:
        r = tank_rect(t)
        pygame.draw.rect(screen, t["color"], r, border_radius=6)
        bx, by = barrel_end(t)
        pygame.draw.line(screen, t["color"], (t["x"], r.top), (bx, by), 5)

        # 체력바: 회색 바탕 위에 체력만큼 채운다
        bar = pygame.Rect(r.centerx - 25, r.top - 16, 50, 7)
        pygame.draw.rect(screen, (220, 220, 220), bar, border_radius=3)
        hp_w = int(50 * t["hp"] / 100)
        pygame.draw.rect(screen, (90, 190, 100) if t["hp"] > 30 else (210, 80, 70),
                         pygame.Rect(bar.x, bar.y, hp_w, bar.h), border_radius=3)

    if shell:
        pygame.draw.circle(screen, (40, 40, 50), (int(shell["x"]), int(shell["y"])), SHELL_R)

    if boom:
        pygame.draw.circle(screen, (255, 190, 60), (int(boom["x"]), int(boom["y"])), int(boom["r"]))
        pygame.draw.circle(screen, (255, 120, 50), (int(boom["x"]), int(boom["y"])), int(boom["r"] * 0.6))

    screen.blit(font.render(f"각도 {me[&#x27;angle']:.0f}°", True, (25, 30, 40)), (16, 14))
    screen.blit(font.render(f"힘 {me[&#x27;power']:.0f}", True, (25, 30, 40)), (16, 40))
    wind_txt = f"바람 {&#x27;→' if wind > 0 else '←'} {abs(wind) * 100:.0f}"
    screen.blit(font.render(wind_txt, True, (25, 30, 40)), (16, 94))
    gauge = pygame.Rect(16, 68, 200, 14)
    pygame.draw.rect(screen, (230, 230, 230), gauge, border_radius=7)
    filled = pygame.Rect(16, 68, int(200 * me["power"] / 100), 14)
    pygame.draw.rect(screen, (240, 150, 60), filled, border_radius=7)

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

pygame.quit()
바람 세기는 아주 작게! 0.05는 한 프레임에 고작 0.05픽셀입니다. 하지만 포탄이 100프레임 날아가면 누적되어 수백 픽셀이 밀립니다. 0.5 같은 값을 주면 포탄이 하늘로 날아가 버립니다. 중력이 0.2인 것과 비교해보면 감이 옵니다.
abs(wind) * 100으로 표시할까? 0.037 같은 숫자는 플레이어에게 아무 의미가 없습니다. 100을 곱해 "바람 → 4" 처럼 보여주면 직관적입니다. 내부 계산용 숫자와 플레이어에게 보여주는 숫자를 분리하는 것은 게임 UI의 기본입니다. (힘을 0~100으로 보여주고 0.22를 곱해 쓰는 것과 같은 이유)
직접 해보기 바람 범위를 (-0.15, 0.15)로 키워 태풍 모드를 만들어보세요. 그리고 바람 표시를 숫자 대신 화살표 개수(→→→)로 바꿔보세요.

11지형 파괴 — 리스트로 만든 보람

1번 챕터에서 땅을 리스트로 만들어둔 이유가 드디어 나옵니다. 포탄이 터진 자리의 ground 값만 바꾸면 땅이 실제로 파입니다.

구덩이는 어떻게 계산할까?

폭발 지점을 중심으로 원 모양으로 파내야 합니다. 원의 방정식을 쓰면, 중심에서 가로로 dx만큼 떨어진 곳에서 원의 아래쪽 가장자리는 이만큼 내려갑니다.

깊이 = √(r² − dx²)
폭발 중심 r dx √(r²−dx²) 중심에서 멀수록 얕게 파인다
import pygame
import math
import random

pygame.init()
W, H = 900, 600
screen = pygame.display.set_mode((W, H))
pygame.display.set_caption("탱크 게임")
clock = pygame.time.Clock()
font = pygame.font.SysFont("malgungothic", 20)

SKY = (140, 185, 225)
GROUND_COLOR = (80, 120, 70)
TANK_W, TANK_H = 46, 20
BARREL_LEN = 42
POWER_SCALE = 0.22                             # 힘(0~100)을 실제 속도로
SHELL_R = 5
GRAVITY = 0.2                                  # 매 프레임 아래로 더해지는 속도
BOOM_MAX = 40                                  # 폭발이 커지는 최대 크기

ground = []
for x in range(W):
    y = H - 150 - 38 * math.sin(x / 120) - 18 * math.sin(x / 43)
    ground.append(y)

def dig(cx, cy, r):
    # (cx, cy)를 중심으로 반지름 r만큼 땅을 파낸다
    for x in range(max(0, int(cx - r)), min(W, int(cx + r))):
        dx = x - cx
        inside = r * r - dx * dx
        if inside > 0:                             # 원 안쪽에 있는 x만
            bottom = cy + math.sqrt(inside)        # 구덩이의 아래 가장자리
            if bottom > ground[x]:                 # 지금 땅보다 아래면
                ground[x] = min(float(H), bottom)  # 그만큼 파낸다

def ground_y(x):
    xi = max(0, min(W - 1, int(x)))
    return ground[xi]

tanks = [
    {"x": 120.0, "angle": 45.0, "power": 55.0, "hp": 100,
     "color": (55, 105, 200), "name": "내 탱크"},
    {"x": W - 120.0, "angle": 135.0, "power": 55.0, "hp": 100,
     "color": (200, 80, 70), "name": "적 탱크"},
]
shell = None                                   # 날아가는 포탄 (없으면 None)
boom = None                                    # 폭발 효과 {"x","y","r"}
turn = 0                                       # 0 = 내 탱크, 1 = 적 탱크
state = "aim"                                  # "aim" / "fly" / "boom"
enemy_miss = None                              # 적이 지난번 얼마나 빗나갔는지
wind = 0.0                                     # 바람 세기 (양수면 오른쪽)

def tank_rect(t):
    y = ground_y(t["x"])
    return pygame.Rect(int(t["x"] - TANK_W / 2), int(y - TANK_H), TANK_W, TANK_H)

def barrel_end(t):
    rad = math.radians(t["angle"])
    bx = t["x"] + math.cos(rad) * BARREL_LEN
    by = (ground_y(t["x"]) - TANK_H) - math.sin(rad) * BARREL_LEN
    return bx, by

def explode(x, y):
    # 포탄을 없애고 그 자리에 폭발을 만든다
    global shell, boom, state, enemy_miss
    dig(x, y, 34)                              # 터진 자리를 파낸다
    shell = None
    boom = {"x": x, "y": y, "r": 6.0}
    # 폭발 지점에서 가까운 탱크에 피해를 준다
    for t in tanks:
        cx, cy = t["x"], ground_y(t["x"]) - TANK_H / 2
        d = math.hypot(cx - x, cy - y)
        if d < 70:
            t["hp"] -= int(50 * (1 - d / 70)) + 8   # 가까울수록 큰 피해
            t["hp"] = max(0, t["hp"])
    if turn == 1:                              # 적이 쏜 포탄이었다면
        enemy_miss = x - tanks[0]["x"]         # 내 탱크와 얼마나 떨어졌나 기억
    state = "boom"                             # 폭발 단계로

def next_turn():
    # 폭발이 끝나면 차례를 넘긴다
    global turn, state, wind
    turn = 1 - turn                            # 0↔1 뒤집기
    wind = random.uniform(-0.05, 0.05)         # 차례마다 바람이 바뀐다
    state = "aim"
    if turn == 1:
        enemy_turn()

def enemy_turn():
    # 지난번 빗나간 거리를 보고 힘을 조절한다
    t = tanks[1]
    t["angle"] = random.uniform(120, 150)
    if enemy_miss is None:
        t["power"] = random.uniform(45, 70)    # 첫 발은 감으로 쏜다
    else:
        # 빗나간 만큼 힘을 조정한다 (20~95 사이로 제한)
        t["power"] = max(20.0, min(95.0, t["power"] + enemy_miss * 0.06))
    fire(t)

def fire(t):
    # 각도와 힘으로 포탄의 처음 속도를 만든다
    global shell, state
    rad = math.radians(t["angle"])
    speed = t["power"] * POWER_SCALE
    bx, by = barrel_end(t)                     # 포신 끝에서 출발
    shell = {
        "x": bx,
        "y": by,
        "vx": math.cos(rad) * speed,           # 가로 속도
        "vy": -math.sin(rad) * speed,          # 세로 속도 (위가 음수)
    }
    state = "fly"                              # 쏘면 비행 단계로

running = True
while running:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
        if event.type == pygame.KEYDOWN and event.key == pygame.K_SPACE:
            if state == "aim" and turn == 0:   # 내 차례, 조준 중일 때만 발사
                fire(tanks[0])

    # 내 차례에 조준 중일 때만 각도·힘을 바꿀 수 있다
    me = tanks[0]
    if state == "aim" and turn == 0:
        keys = pygame.key.get_pressed()
        if keys[pygame.K_LEFT]:
            me["angle"] = min(180.0, me["angle"] + 0.8)
        if keys[pygame.K_RIGHT]:
            me["angle"] = max(0.0, me["angle"] - 0.8)
        if keys[pygame.K_UP]:
            me["power"] = min(100.0, me["power"] + 0.6)
        if keys[pygame.K_DOWN]:
            me["power"] = max(5.0, me["power"] - 0.6)

    # 포탄을 속도만큼 움직인다 (벽돌깨기의 공과 똑같다)
    if state == "fly" and shell:
        shell["vx"] += wind                    # 바람이 가로로 밀어준다
        shell["vy"] += GRAVITY                 # 이 한 줄이 포물선을 만든다
        shell["x"] += shell["vx"]
        shell["y"] += shell["vy"]

        # 탱크에 직접 맞았는지 먼저 확인한다
        hit_tank = None
        for t in tanks:
            if tank_rect(t).collidepoint(shell["x"], shell["y"]):
                hit_tank = t

        if hit_tank:
            explode(shell["x"], shell["y"])
        elif shell["y"] >= ground_y(shell["x"]):    # 땅에 닿았으면 폭발
            explode(shell["x"], ground_y(shell["x"]))
        elif shell["x"] < -300 or shell["x"] > W + 300 or shell["y"] > H + 600:
            shell = None                       # 화면 밖으로 나가면 사라짐

    # 폭발이 점점 커지다가 사라진다
    if state == "boom" and boom:
        boom["r"] += 2.5
        if boom["r"] >= BOOM_MAX:
            boom = None
            next_turn()                        # 폭발이 끝나야 다음 차례

    screen.fill(SKY)
    points = [(0, H)] + [(x, ground[x]) for x in range(W)] + [(W, H)]
    pygame.draw.polygon(screen, GROUND_COLOR, points)

    for t in tanks:
        r = tank_rect(t)
        pygame.draw.rect(screen, t["color"], r, border_radius=6)
        bx, by = barrel_end(t)
        pygame.draw.line(screen, t["color"], (t["x"], r.top), (bx, by), 5)

        # 체력바: 회색 바탕 위에 체력만큼 채운다
        bar = pygame.Rect(r.centerx - 25, r.top - 16, 50, 7)
        pygame.draw.rect(screen, (220, 220, 220), bar, border_radius=3)
        hp_w = int(50 * t["hp"] / 100)
        pygame.draw.rect(screen, (90, 190, 100) if t["hp"] > 30 else (210, 80, 70),
                         pygame.Rect(bar.x, bar.y, hp_w, bar.h), border_radius=3)

    if shell:
        pygame.draw.circle(screen, (40, 40, 50), (int(shell["x"]), int(shell["y"])), SHELL_R)

    if boom:
        pygame.draw.circle(screen, (255, 190, 60), (int(boom["x"]), int(boom["y"])), int(boom["r"]))
        pygame.draw.circle(screen, (255, 120, 50), (int(boom["x"]), int(boom["y"])), int(boom["r"] * 0.6))

    screen.blit(font.render(f"각도 {me[&#x27;angle']:.0f}°", True, (25, 30, 40)), (16, 14))
    screen.blit(font.render(f"힘 {me[&#x27;power']:.0f}", True, (25, 30, 40)), (16, 40))
    wind_txt = f"바람 {&#x27;→' if wind > 0 else '←'} {abs(wind) * 100:.0f}"
    screen.blit(font.render(wind_txt, True, (25, 30, 40)), (16, 94))
    gauge = pygame.Rect(16, 68, 200, 14)
    pygame.draw.rect(screen, (230, 230, 230), gauge, border_radius=7)
    filled = pygame.Rect(16, 68, int(200 * me["power"] / 100), 14)
    pygame.draw.rect(screen, (240, 150, 60), filled, border_radius=7)

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

pygame.quit()
탱크는 자동으로 따라 내려옵니다! tank_rect()가 매 프레임 ground_y(t["x"])로 위치를 계산하기 때문에, 발밑이 파이면 탱크도 같이 내려앉습니다. 코드를 한 줄도 더 안 써도 됩니다 — 1번 챕터에서 구조를 잘 짜둔 덕분입니다.
ground[x] = min(float(H), bottom)에서 min을 쓰는 이유는, 계속 파다 보면 땅이 화면 아래로 무한정 내려가 버리기 때문입니다. 화면 높이 H를 바닥으로 정해둡니다.
직접 해보기
  • dig(x, y, 34)의 34를 100으로 바꿔 초강력 폭탄을 만들어보세요. 땅에 큰 분화구가 생깁니다.
  • 같은 자리를 계속 쏘면 어떻게 되나요? 적 탱크 발밑을 계속 파면 탱크가 구덩이에 빠져 맞히기 어려워질까요, 쉬워질까요?

12승패와 마무리 — 게임 완성

마지막입니다. 체력이 0이 되면 게임이 끝나고, 스페이스로 다시 시작할 수 있게 만듭니다.

import pygame
import math
import random

pygame.init()
W, H = 900, 600
screen = pygame.display.set_mode((W, H))
pygame.display.set_caption("탱크 게임")
clock = pygame.time.Clock()
font = pygame.font.SysFont("malgungothic", 20)
big_font = pygame.font.SysFont("malgungothic", 40, bold=True)

SKY = (140, 185, 225)
GROUND_COLOR = (80, 120, 70)
TANK_W, TANK_H = 46, 20
BARREL_LEN = 42
POWER_SCALE = 0.22                             # 힘(0~100)을 실제 속도로
SHELL_R = 5
GRAVITY = 0.2                                  # 매 프레임 아래로 더해지는 속도
BOOM_MAX = 40                                  # 폭발이 커지는 최대 크기

ground = []

def make_ground():
    # sin을 두 개 겹쳐서 완만한 언덕 지형을 만든다
    global ground
    ground = []
    for x in range(W):
        y = H - 150 - 38 * math.sin(x / 120) - 18 * math.sin(x / 43)
        ground.append(y)

make_ground()

def dig(cx, cy, r):
    # (cx, cy)를 중심으로 반지름 r만큼 땅을 파낸다
    for x in range(max(0, int(cx - r)), min(W, int(cx + r))):
        dx = x - cx
        inside = r * r - dx * dx
        if inside > 0:                             # 원 안쪽에 있는 x만
            bottom = cy + math.sqrt(inside)        # 구덩이의 아래 가장자리
            if bottom > ground[x]:                 # 지금 땅보다 아래면
                ground[x] = min(float(H), bottom)  # 그만큼 파낸다

def ground_y(x):
    xi = max(0, min(W - 1, int(x)))
    return ground[xi]

tanks = [
    {"x": 120.0, "angle": 45.0, "power": 55.0, "hp": 100,
     "color": (55, 105, 200), "name": "내 탱크"},
    {"x": W - 120.0, "angle": 135.0, "power": 55.0, "hp": 100,
     "color": (200, 80, 70), "name": "적 탱크"},
]
shell = None                                   # 날아가는 포탄 (없으면 None)
boom = None                                    # 폭발 효과 {"x","y","r"}
turn = 0                                       # 0 = 내 탱크, 1 = 적 탱크
state = "aim"                                  # "aim" / "fly" / "boom" / "over"
enemy_miss = None                              # 적이 지난번 얼마나 빗나갔는지
wind = 0.0                                     # 바람 세기 (양수면 오른쪽)

def tank_rect(t):
    y = ground_y(t["x"])
    return pygame.Rect(int(t["x"] - TANK_W / 2), int(y - TANK_H), TANK_W, TANK_H)

def barrel_end(t):
    rad = math.radians(t["angle"])
    bx = t["x"] + math.cos(rad) * BARREL_LEN
    by = (ground_y(t["x"]) - TANK_H) - math.sin(rad) * BARREL_LEN
    return bx, by

def explode(x, y):
    # 포탄을 없애고 그 자리에 폭발을 만든다
    global shell, boom, state, enemy_miss
    dig(x, y, 34)                              # 터진 자리를 파낸다
    shell = None
    boom = {"x": x, "y": y, "r": 6.0}
    # 폭발 지점에서 가까운 탱크에 피해를 준다
    for t in tanks:
        cx, cy = t["x"], ground_y(t["x"]) - TANK_H / 2
        d = math.hypot(cx - x, cy - y)
        if d < 70:
            t["hp"] -= int(50 * (1 - d / 70)) + 8   # 가까울수록 큰 피해
            t["hp"] = max(0, t["hp"])
    if turn == 1:                              # 적이 쏜 포탄이었다면
        enemy_miss = x - tanks[0]["x"]         # 내 탱크와 얼마나 떨어졌나 기억
    state = "boom"                             # 폭발 단계로

def next_turn():
    # 폭발이 끝나면 차례를 넘긴다
    global turn, state, wind
    # 누군가 쓰러졌으면 게임 끝
    if tanks[0]["hp"] <= 0 or tanks[1]["hp"] <= 0:
        state = "over"
        return
    turn = 1 - turn                            # 0↔1 뒤집기
    wind = random.uniform(-0.05, 0.05)         # 차례마다 바람이 바뀐다
    state = "aim"
    if turn == 1:
        enemy_turn()

def enemy_turn():
    # 지난번 빗나간 거리를 보고 힘을 조절한다
    t = tanks[1]
    t["angle"] = random.uniform(120, 150)
    if enemy_miss is None:
        t["power"] = random.uniform(45, 70)    # 첫 발은 감으로 쏜다
    else:
        # 빗나간 만큼 힘을 조정한다 (20~95 사이로 제한)
        t["power"] = max(20.0, min(95.0, t["power"] + enemy_miss * 0.06))
    fire(t)

def fire(t):
    # 각도와 힘으로 포탄의 처음 속도를 만든다
    global shell, state
    rad = math.radians(t["angle"])
    speed = t["power"] * POWER_SCALE
    bx, by = barrel_end(t)                     # 포신 끝에서 출발
    shell = {
        "x": bx,
        "y": by,
        "vx": math.cos(rad) * speed,           # 가로 속도
        "vy": -math.sin(rad) * speed,          # 세로 속도 (위가 음수)
    }
    state = "fly"                              # 쏘면 비행 단계로

def reset_game():
    # 게임 전체를 처음 상태로 되돌린다
    global tanks, shell, boom, wind, turn, state, enemy_miss
    make_ground()                              # 지형도 새로 만든다
    tanks = [
        {"x": 120.0, "angle": 45.0, "power": 55.0, "hp": 100,
         "color": (55, 105, 200), "name": "내 탱크"},
        {"x": W - 120.0, "angle": 135.0, "power": 55.0, "hp": 100,
         "color": (200, 80, 70), "name": "적 탱크"},
    ]
    shell = None
    boom = None
    wind = random.uniform(-0.05, 0.05)
    turn = 0
    state = "aim"
    enemy_miss = None

reset_game()

running = True
while running:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
        if event.type == pygame.KEYDOWN and event.key == pygame.K_SPACE:
            if state == "aim" and turn == 0:   # 내 차례, 조준 중일 때만 발사
                fire(tanks[0])
            elif state == "over":
                reset_game()                   # 끝난 상태면 다시 시작

    # 내 차례에 조준 중일 때만 각도·힘을 바꿀 수 있다
    me = tanks[0]
    if state == "aim" and turn == 0:
        keys = pygame.key.get_pressed()
        if keys[pygame.K_LEFT]:
            me["angle"] = min(180.0, me["angle"] + 0.8)
        if keys[pygame.K_RIGHT]:
            me["angle"] = max(0.0, me["angle"] - 0.8)
        if keys[pygame.K_UP]:
            me["power"] = min(100.0, me["power"] + 0.6)
        if keys[pygame.K_DOWN]:
            me["power"] = max(5.0, me["power"] - 0.6)

    # 포탄을 속도만큼 움직인다 (벽돌깨기의 공과 똑같다)
    if state == "fly" and shell:
        shell["vx"] += wind                    # 바람이 가로로 밀어준다
        shell["vy"] += GRAVITY                 # 이 한 줄이 포물선을 만든다
        shell["x"] += shell["vx"]
        shell["y"] += shell["vy"]

        # 탱크에 직접 맞았는지 먼저 확인한다
        hit_tank = None
        for t in tanks:
            if tank_rect(t).collidepoint(shell["x"], shell["y"]):
                hit_tank = t

        if hit_tank:
            explode(shell["x"], shell["y"])
        elif shell["y"] >= ground_y(shell["x"]):    # 땅에 닿았으면 폭발
            explode(shell["x"], ground_y(shell["x"]))
        elif shell["x"] < -300 or shell["x"] > W + 300 or shell["y"] > H + 600:
            shell = None                       # 화면 밖으로 나가면 사라짐

    # 폭발이 점점 커지다가 사라진다
    if state == "boom" and boom:
        boom["r"] += 2.5
        if boom["r"] >= BOOM_MAX:
            boom = None
            next_turn()                        # 폭발이 끝나야 다음 차례

    screen.fill(SKY)
    points = [(0, H)] + [(x, ground[x]) for x in range(W)] + [(W, H)]
    pygame.draw.polygon(screen, GROUND_COLOR, points)

    for t in tanks:
        r = tank_rect(t)
        pygame.draw.rect(screen, t["color"], r, border_radius=6)
        bx, by = barrel_end(t)
        pygame.draw.line(screen, t["color"], (t["x"], r.top), (bx, by), 5)

        # 체력바: 회색 바탕 위에 체력만큼 채운다
        bar = pygame.Rect(r.centerx - 25, r.top - 16, 50, 7)
        pygame.draw.rect(screen, (220, 220, 220), bar, border_radius=3)
        hp_w = int(50 * t["hp"] / 100)
        pygame.draw.rect(screen, (90, 190, 100) if t["hp"] > 30 else (210, 80, 70),
                         pygame.Rect(bar.x, bar.y, hp_w, bar.h), border_radius=3)

    if shell:
        pygame.draw.circle(screen, (40, 40, 50), (int(shell["x"]), int(shell["y"])), SHELL_R)

    if boom:
        pygame.draw.circle(screen, (255, 190, 60), (int(boom["x"]), int(boom["y"])), int(boom["r"]))
        pygame.draw.circle(screen, (255, 120, 50), (int(boom["x"]), int(boom["y"])), int(boom["r"] * 0.6))

    screen.blit(font.render(f"각도 {me[&#x27;angle']:.0f}°", True, (25, 30, 40)), (16, 14))
    screen.blit(font.render(f"힘 {me[&#x27;power']:.0f}", True, (25, 30, 40)), (16, 40))
    wind_txt = f"바람 {&#x27;→' if wind > 0 else '←'} {abs(wind) * 100:.0f}"
    screen.blit(font.render(wind_txt, True, (25, 30, 40)), (16, 94))
    gauge = pygame.Rect(16, 68, 200, 14)
    pygame.draw.rect(screen, (230, 230, 230), gauge, border_radius=7)
    filled = pygame.Rect(16, 68, int(200 * me["power"] / 100), 14)
    pygame.draw.rect(screen, (240, 150, 60), filled, border_radius=7)

    turn_txt = "내 차례 (←→ 각도, ↑↓ 힘, 스페이스 발사)" if turn == 0 else "적 차례..."
    screen.blit(font.render(turn_txt, True, (25, 30, 40)), (16, H - 34))

    if state == "over":
        win = "승리!" if tanks[1]["hp"] <= 0 else "패배..."
        msg = big_font.render(win, True, (25, 30, 40))
        sub = font.render("스페이스를 누르면 다시 시작", True, (25, 30, 40))
        screen.blit(msg, msg.get_rect(center=(W // 2, H // 2 - 20)))
        screen.blit(sub, sub.get_rect(center=(W // 2, H // 2 + 30)))

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

pygame.quit()

여기까지 하면 완성된 탱크 게임입니다. 왼쪽 사이드바 아래의 "완성 코드 내려받기"로 전체 코드를 받아서 지금 만든 것과 비교해보세요.

2주 동안 배운 것 정리

배운 것어디에 썼나
sin, cos각도 → 포신 끝 좌표, 각도+힘 → 포탄 속도
math.radians도를 라디안으로 바꾸기
중력 (vy += g)포물선 궤적
math.hypot거리에 따른 피해 계산
정규화 (1 − d/r)거리를 0~1 비율로
상태 기계aim → fly → boom → 다음 차례
비례 제어적 AI가 오차만큼 힘을 조정
리스트로 지형 관리땅 파괴
더 만들어보기
  • 탱크 이동 — 차례마다 연료를 주고 좌우로 조금 움직일 수 있게
  • 무기 종류 — 일반탄 / 큰 폭발탄 / 세 발로 갈라지는 탄
  • 궤적 미리보기 — 지난번 쏜 포탄의 자취를 점선으로 남기기
  • 2인 플레이 — 적 AI 대신 사람이 조작 (turn만 바꾸면 거의 다 됩니다)
  • 포탄 회전 — 포탄이 나아가는 방향을 math.atan2(vy, vx)로 구해 그림을 회전시키기