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픽셀씩 출렁이는 언덕이 됩니다.
38— 언덕의 높이 (크게 하면 산, 작게 하면 평지)x / 120— 언덕의 폭 (나누는 수가 크면 완만, 작으면 촘촘)- 두 개를 겹친 이유 — 하나만 쓰면 너무 규칙적이라 인공적으로 보입니다
sin을 "물결을 만드는 도구"로 처음 만났습니다. 다음 챕터에서는 같은 sin이 각도를 방향으로 바꾸는 도구로 쓰입니다. 사실 이 둘은 같은 원리인데, 2번 챕터에서 그 이유를 자세히 설명합니다.
38을 100으로 바꿔 험한 산을 만들어보세요. x / 120을 x / 30으로 바꾸면 어떻게 되나요? 두 sin 중 하나를 지우면 땅이 어떻게 달라지나요?
2포신 각도 조절 — sin·cos 완전 정복 ★
이번 챕터는 이 프로젝트에서 가장 중요합니다. 코드는 몇 줄 안 되지만, 그 몇 줄을 이해하는 게 전부입니다. 천천히 읽으세요.
우리가 풀어야 할 문제는 이것입니다: "각도가 45도일 때, 포신 끝은 화면의 어디일까?"
📐 1부 — 각도란 무엇인가
각도의 기준: 0도는 "오른쪽"
수학에서는 오른쪽 방향을 0도로 정하고, 반시계 방향(위쪽)으로 돌면서 각도가 커진다고 약속했습니다. 우리도 이 약속을 그대로 씁니다.
| 각도 | 포신이 향하는 곳 | 언제 쓰나 |
|---|---|---|
| 0° | 오른쪽 수평 | 바로 앞 목표 |
| 45° | 오른쪽 위 대각선 | 가장 멀리 보낼 때 |
| 90° | 똑바로 위 | 제자리에 떨어짐 |
| 135° | 왼쪽 위 대각선 | 왼쪽 목표 (적 탱크용) |
| 180° | 왼쪽 수평 | 왼쪽 바로 앞 |
📐 2부 — cos과 sin은 무슨 뜻인가
각도를 정했다고 해서 컴퓨터가 "어느 쪽인지" 아는 게 아닙니다. 컴퓨터는 "가로로 몇 칸, 세로로 몇 칸"만 이해합니다. 그래서 각도를 가로·세로 숫자로 번역해야 하는데, 그 번역기가 바로 cos과 sin입니다.
·
cos(각도) = 그 방향으로 1만큼 갈 때 가로로 가는 정도·
sin(각도) = 그 방향으로 1만큼 갈 때 세로로 가는 정도
즉 cos과 sin은 "비율"입니다. 항상 −1에서 1 사이의 값이고, 여기에 실제 길이를 곱하면 실제 거리가 나옵니다.
| 각도 | cos (가로 비율) | sin (세로 비율) | 포신 길이 42일 때 |
|---|---|---|---|
| 0° | 1.00 | 0.00 | 가로 +42, 세로 0 (수평) |
| 30° | 0.87 | 0.50 | 가로 +36, 세로 21 위 |
| 45° | 0.71 | 0.71 | 가로 +30, 세로 30 위 |
| 60° | 0.50 | 0.87 | 가로 +21, 세로 36 위 |
| 90° | 0.00 | 1.00 | 가로 0, 세로 42 위 (수직) |
| 135° | −0.71 | 0.71 | 가로 −30(왼쪽), 세로 30 위 |
| 180° | −1.00 | 0.00 | 가로 −42(왼쪽), 세로 0 |
각도가 커질수록 cos은 작아지고 sin은 커집니다. 포신을 위로 들수록 앞으로는 덜 나가고 위로 더 향하니까 당연하죠. 90도를 넘으면 cos이 음수가 되는데, 이건 "가로로 왼쪽" 이라는 뜻입니다.
📐 3부 — 코드로 옮길 때 주의할 점 두 가지
주의 1. 파이썬은 "도"가 아니라 "라디안"을 쓴다
math.cos(45)라고 쓰면 틀립니다. 파이썬의 cos, sin은 각도 단위로 라디안을 받기 때문입니다. 라디안은 각도를 재는 또 다른 단위입니다.
| 도(degree) | 라디안(radian) |
|---|---|
| 0° | 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 값을 그대로 쓰면 포신이 아래를 향합니다.
해결은 간단합니다. 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%만큼"이라는 뜻입니다. 벽돌깨기와 피하기 게임의 체력·점수 표시도 같은 원리로 만들 수 있습니다.
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)로 변환합니다.
포탄이 날아가는 방식은 벽돌깨기의 공과 완전히 똑같습니다 — 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()
실행해서 스페이스를 눌러보세요. 포탄이 직선으로 쭉 날아갑니다. 아직 중력이 없어서 그렇습니다. 다음 챕터에서 한 줄만 더하면 포물선이 됩니다.
5중력과 포물선
벽돌깨기 8번 챕터에서 실험했던 바로 그 한 줄입니다.
shell["vy"] += GRAVITY
매 프레임 세로 속도에 조금씩 더하면, 포탄은 올라가다가 느려지고, 멈췄다가, 내려오면서 빨라집니다. 이것이 포물선입니다.
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['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()
각도와 사거리의 관계 — 왜 45도가 가장 멀까?
같은 힘으로 쏠 때 각도에 따라 얼마나 날아가는지 직접 실험해보면 재미있는 사실을 발견합니다.
| 각도 | 결과 | 이유 |
|---|---|---|
| 15° | 가깝게 떨어짐 | 앞으로는 빠른데 금방 땅에 닿음 (체공 시간 짧음) |
| 45° | 가장 멀리 | 앞으로 가는 속도와 떠 있는 시간의 균형이 가장 좋음 |
| 75° | 가깝게 떨어짐 | 오래 떠 있지만 앞으로 거의 안 나감 |
| 90° | 제자리 | 가로 속도가 0이라 쏜 자리로 다시 떨어짐 |
- 힘을 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['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()
if와 elif로 이어야 합니다. 땅에 닿은 프레임에 화면 밖 조건까지 검사해버리면, 이미 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
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['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()
70을 150으로 키워보세요. 훨씬 쉬워지죠? 반대로 30으로 줄이면 정확히 맞혀야만 피해를 줄 수 있습니다. 게임의 난이도가 숫자 하나로 확 바뀌는 걸 느껴보세요.
8턴 방식으로 번갈아 쏘기
지금은 내가 무한정 쏠 수 있습니다. 이제 내 차례 → 적 차례 → 내 차례…로 번갈아 가도록 만듭니다. 이를 위해 두 개의 변수가 필요합니다.
turn— 지금 누구 차례인가 (0 = 나, 1 = 적)state— 지금 무슨 단계인가
이렇게 상태가 순서대로 흘러가는 구조를 상태 기계라고 부릅니다. 피하기 게임과 벽돌깨기에서 "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['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()
turn = 1 - turn은 0과 1을 번갈아 뒤집는 방법입니다. 0이면 1−0=1, 1이면 1−1=0이 되죠. if문을 쓰지 않고 한 줄로 해결하는 흔한 요령입니다.
import random을 추가해야 random.uniform이 동작합니다.
9적 AI — 점점 정확해지는 적
지금 적은 완전히 무작위로 쏴서 전혀 위협적이지 않습니다. 진짜 사람처럼 빗나간 만큼 보고 다음 발을 조정하게 만들어봅시다. 실제 포병이 "초탄 관측 후 수정사격"을 하는 방식과 같습니다.
적은 오른쪽에 있고 왼쪽으로 쏩니다. 포탄이 내 탱크보다 오른쪽에 떨어졌다면(오차가 양수) 덜 날아간 것이니 힘을 키워야 합니다. 반대면 줄입니다. 그래서 오차에 비례해 힘을 조정하면 됩니다.
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['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.06이라는 숫자의 의미
이 숫자가 적의 똑똑함을 결정합니다. 이런 방식을 "오차에 비례해 조정한다"고 해서 비례 제어라고 부르는데, 로봇이나 드론이 목표를 따라갈 때도 쓰는 실제 기법입니다.
| 값 | 적의 성격 | 결과 |
|---|---|---|
| 0.01 | 아주 조심스러움 | 천천히 맞혀옴 — 쉬움 |
| 0.06 | 적당함 | 3~5발이면 근처에 맞힘 |
| 0.3 | 성급함 | 너무 많이 고쳐서 좌우로 왔다갔다 (수렴 못 함) |
0.06을0.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['angle']:.0f}°", True, (25, 30, 40)), (16, 14))
screen.blit(font.render(f"힘 {me['power']:.0f}", True, (25, 30, 40)), (16, 40))
wind_txt = f"바람 {'→' 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²)
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['angle']:.0f}°", True, (25, 30, 40)), (16, 14))
screen.blit(font.render(f"힘 {me['power']:.0f}", True, (25, 30, 40)), (16, 40))
wind_txt = f"바람 {'→' 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['angle']:.0f}°", True, (25, 30, 40)), (16, 14))
screen.blit(font.render(f"힘 {me['power']:.0f}", True, (25, 30, 40)), (16, 40))
wind_txt = f"바람 {'→' 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)로 구해 그림을 회전시키기