완성된 모습 — 8번 챕터까지 만들면 이렇게 동작합니다
각 챕터는 이전 챕터의 전체 코드 + 새로 추가된 부분입니다.  파란색으로 밑칠된 줄 이 이번 챕터에서 새로 생긴 부분입니다.

1화면과 패들 만들기

벽돌깨기의 뼈대를 먼저 세웁니다. 뱀게임과 달리 이번에는 격자(칸)를 쓰지 않습니다. 공도 패들도 픽셀 단위로 자유롭게 움직일 거라서, 위치를 pygame.Rect와 숫자로 직접 관리합니다.

import pygame

pygame.init()
W, H = 640, 700                                # 창의 가로, 세로 크기
screen = pygame.display.set_mode((W, H))
pygame.display.set_caption("벽돌깨기")
clock = pygame.time.Clock()

PADDLE_W, PADDLE_H = 110, 14                   # 패들(막대)의 크기
paddle = pygame.Rect(0, 0, PADDLE_W, PADDLE_H) # 일단 (0,0)에 만들고
paddle.centerx = W // 2                        # 가로는 화면 가운데로
paddle.y = H - 50                              # 세로는 바닥에서 50픽셀 위

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

    screen.fill((18, 20, 32))                  # 어두운 남색 배경
    pygame.draw.rect(screen, (90, 200, 255), paddle, border_radius=7)

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

pygame.quit()

paddle.centerx = W // 2처럼 Rect는 위치를 여러 이름으로 지정할 수 있습니다. x(왼쪽), centerx(가로 중심), right(오른쪽 끝) 중 편한 것을 쓰면 나머지는 파이게임이 알아서 계산해줍니다. 이 성질은 4번 챕터에서 "화면 밖으로 나가지 않게" 막을 때 아주 요긴하게 쓰입니다.

직접 해보기 paddle.y = H - 50의 50을 200으로 바꿔보세요. 패들이 어디로 가나요? PADDLE_W를 300으로 바꾸면 패들이 어떻게 달라지나요?

2패들 움직이기 (꾹 누르면 계속)

여기서 뱀게임과 결정적으로 다른 점이 나옵니다. 뱀게임은 KEYDOWN 이벤트로 "키를 누른 순간"에만 방향을 바꿨습니다. 그런데 패들은 키를 누르고 있는 동안 계속 움직여야 합니다. 이럴 때는 pygame.key.get_pressed()를 씁니다.

방식언제 실행되나어디에 쓰나
KEYDOWN 이벤트키를 누른 그 순간 딱 1번뱀 방향 바꾸기, 점프, 발사
get_pressed()매 프레임(초당 60번) 확인패들·캐릭터 이동처럼 계속되는 동작
import pygame

pygame.init()
W, H = 640, 700
screen = pygame.display.set_mode((W, H))
pygame.display.set_caption("벽돌깨기")
clock = pygame.time.Clock()

PADDLE_W, PADDLE_H = 110, 14
PADDLE_SPEED = 8                               # 한 프레임에 8픽셀씩 이동
paddle = pygame.Rect(0, 0, PADDLE_W, PADDLE_H)
paddle.centerx = W // 2
paddle.y = H - 50

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

    # 지금 눌려 있는 모든 키를 한 번에 가져온다
    keys = pygame.key.get_pressed()
    if keys[pygame.K_LEFT]:
        paddle.x -= PADDLE_SPEED               # 왼쪽 키를 누르고 있으면 왼쪽으로
    if keys[pygame.K_RIGHT]:
        paddle.x += PADDLE_SPEED               # 오른쪽 키를 누르고 있으면 오른쪽으로

    # 화면 밖으로 나가지 못하게 붙잡아둔다
    if paddle.left < 0:
        paddle.left = 0
    if paddle.right > W:
        paddle.right = W

    screen.fill((18, 20, 32))
    pygame.draw.rect(screen, (90, 200, 255), paddle, border_radius=7)

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

pygame.quit()

화면 밖 막기를 왜 이렇게 할까요? "왼쪽 키를 누르면 무조건 8픽셀 이동"시켜 놓고, 그다음에 "너무 갔으면 되돌린다"는 방식입니다. 이동 전에 미리 계산해서 막는 것보다 코드가 훨씬 짧고, 나중에 속도가 바뀌어도 그대로 동작합니다. 게임에서 자주 쓰는 방법입니다.

직접 해보기 화면 밖 막기 코드 4줄을 잠시 지우고 실행해보세요. 패들이 어떻게 되나요? 그다음 PADDLE_SPEED를 30으로 올려보고, 조작감이 어떻게 달라지는지 느껴보세요.

3공 움직이기 — 속도(vx, vy)의 등장 ★

이 챕터가 이 프로젝트에서 가장 중요합니다. 뱀게임에서는 "오른쪽으로 한 칸"처럼 방향만 있었지만, 이제는 속도라는 개념을 씁니다.

그리고 매 프레임 ball_x += ball_vx, ball_y += ball_vy 딱 두 줄로 공을 움직입니다. 이 두 줄이 앞으로 만들 탱크 게임의 포탄과 완전히 똑같은 구조입니다.

W / 2처럼 실수를 쓸까요? 공의 좌표는 4.5, 7.3처럼 소수점이 생깁니다. 정수만 쓰면 방향이 미세하게 틀어지거나 움직임이 뚝뚝 끊깁니다. 그래서 계산은 실수로 하고, 그릴 때만 int()로 바꿔서 그립니다.
import pygame

pygame.init()
W, H = 640, 700
screen = pygame.display.set_mode((W, H))
pygame.display.set_caption("벽돌깨기")
clock = pygame.time.Clock()

PADDLE_W, PADDLE_H = 110, 14
PADDLE_SPEED = 8
paddle = pygame.Rect(0, 0, PADDLE_W, PADDLE_H)
paddle.centerx = W // 2
paddle.y = H - 50

BALL_R = 8                                     # 공의 반지름
BALL_SPEED = 5                                 # 공의 기본 속력
ball_x, ball_y = W / 2, H / 2                  # 공의 위치 (실수로 관리)
ball_vx = BALL_SPEED * 0.7                     # 오른쪽으로 3.5씩
ball_vy = -BALL_SPEED                          # 위로 5씩 (y는 위쪽이 작은 값!)

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

    keys = pygame.key.get_pressed()
    if keys[pygame.K_LEFT]:
        paddle.x -= PADDLE_SPEED
    if keys[pygame.K_RIGHT]:
        paddle.x += PADDLE_SPEED
    if paddle.left < 0:
        paddle.left = 0
    if paddle.right > W:
        paddle.right = W

    # 속도만큼 공을 움직인다 — 이 두 줄이 핵심!
    ball_x += ball_vx
    ball_y += ball_vy

    screen.fill((18, 20, 32))
    pygame.draw.rect(screen, (90, 200, 255), paddle, border_radius=7)
    pygame.draw.circle(screen, (255, 230, 120), (int(ball_x), int(ball_y)), BALL_R)

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

pygame.quit()

실행하면 공이 오른쪽 위로 쭉 날아가서 화면 밖으로 사라집니다. 아직 튕기는 코드가 없으니 당연합니다. 다음 챕터에서 벽에 부딪히게 만듭니다.

y축 방향에 주의! 파이게임에서 y는 아래로 갈수록 커집니다. 그래서 위로 올라가려면 ball_vy음수여야 합니다. 처음에 가장 많이 헷갈리는 부분입니다.
직접 해보기 ball_vy = -BALL_SPEEDball_vy = BALL_SPEED로 바꾸면 공이 어디로 가나요? ball_vx = 0으로 하면 어떻게 되나요? 두 값을 이리저리 바꿔서 "vx, vy가 방향을 정한다"를 몸으로 익히세요.

4벽에 튕기기 — 부호 뒤집기

튕김의 원리는 놀랄 만큼 간단합니다. 부호를 뒤집으면 됩니다.

왜 "하나만" 뒤집을까요?

공이 오른쪽 아래로 가고 있다고 해봅시다 (vx=+4, vy=+3). 오른쪽 벽에 부딪히면 가로로 가던 것만 막히고, 세로로 내려가던 것은 그대로입니다. 그래서 vx만 뒤집어 -4로 만들고 vy+3 그대로 둡니다. 그 결과 공은 왼쪽 아래로 갑니다.

들어올 때 vx=+4, vy=+3 나갈 때 vx=−4, vy=+3 부딪힌 지점
들어오는 각도와 나가는 각도가 거울처럼 대칭이 됩니다. 학교에서 배우는 "입사각 = 반사각"이 코드로는 vx = -vx 한 줄인 셈입니다. 5번 챕터에서는 이 규칙을 일부러 깨뜨려서 더 재미있게 만듭니다.

바닥으로 빠지면 일단 공을 가운데로 되돌립니다. 이 "되돌리기"는 나중에도 계속 쓰이니 reset_ball() 함수로 만들어둡니다.

import pygame

pygame.init()
W, H = 640, 700
screen = pygame.display.set_mode((W, H))
pygame.display.set_caption("벽돌깨기")
clock = pygame.time.Clock()

PADDLE_W, PADDLE_H = 110, 14
PADDLE_SPEED = 8
paddle = pygame.Rect(0, 0, PADDLE_W, PADDLE_H)
paddle.centerx = W // 2
paddle.y = H - 50

BALL_R = 8
BALL_SPEED = 5
ball_x = ball_y = ball_vx = ball_vy = 0.0      # 변수만 먼저 만들어두고

def reset_ball():                              # 공을 가운데로 되돌리는 함수
    global ball_x, ball_y, ball_vx, ball_vy    # 바깥 변수를 바꾸겠다는 뜻
    ball_x, ball_y = W / 2, H / 2
    ball_vx = BALL_SPEED * 0.7
    ball_vy = -BALL_SPEED

reset_ball()                                   # 시작할 때 한 번 호출

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

    keys = pygame.key.get_pressed()
    if keys[pygame.K_LEFT]:
        paddle.x -= PADDLE_SPEED
    if keys[pygame.K_RIGHT]:
        paddle.x += PADDLE_SPEED
    if paddle.left < 0:
        paddle.left = 0
    if paddle.right > W:
        paddle.right = W

    ball_x += ball_vx
    ball_y += ball_vy

    # 왼쪽 벽: 공의 왼쪽 끝(ball_x - 반지름)이 0보다 작아졌다면
    if ball_x - BALL_R < 0:
        ball_x = BALL_R                        # 벽에 딱 붙는 위치로 되돌리고
        ball_vx = -ball_vx                     # 가로 방향을 뒤집는다

    # 오른쪽 벽
    if ball_x + BALL_R > W:
        ball_x = W - BALL_R
        ball_vx = -ball_vx

    # 천장
    if ball_y - BALL_R < 0:
        ball_y = BALL_R
        ball_vy = -ball_vy

    # 바닥으로 빠지면 일단 가운데에서 다시 시작
    if ball_y - BALL_R > H:
        reset_ball()

    screen.fill((18, 20, 32))
    pygame.draw.rect(screen, (90, 200, 255), paddle, border_radius=7)
    pygame.draw.circle(screen, (255, 230, 120), (int(ball_x), int(ball_y)), BALL_R)

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

pygame.quit()
ball_x = BALL_R처럼 위치를 되돌려 놓을까요? 이 줄이 없으면 공이 벽을 살짝 파고든 상태에서 방향만 뒤집힙니다. 그러면 다음 프레임에도 여전히 벽 안에 있어서 또 뒤집히고, 또 뒤집히고… 공이 벽에 붙어 부들부들 떨게 됩니다. 실제로 이 줄들을 지우고 실행해보면 바로 확인할 수 있습니다.
직접 해보기 위 경고대로 ball_x = BALL_R, ball_x = W - BALL_R 두 줄을 지우고 실행해서 공이 벽에서 떠는 걸 직접 확인해보세요. 버그를 일부러 만들어보면 왜 필요한지 확실히 이해됩니다.

5패들로 받아치기 — 맞은 위치로 각도 바꾸기

공과 패들이 겹쳤는지는 colliderect()로 확인합니다. 그런데 공은 Rect가 아니라 좌표와 반지름으로 관리하고 있으니, 충돌 검사용 사각형을 그때그때 만들어서 비교합니다.

그냥 위로 튕기기만 하면 공이 늘 같은 각도로만 움직여서 금방 지루해집니다. 그래서 패들의 어디에 맞았는지에 따라 가로 속도를 바꿔줍니다. 이게 벽돌깨기의 진짜 재미입니다.

import pygame

pygame.init()
W, H = 640, 700
screen = pygame.display.set_mode((W, H))
pygame.display.set_caption("벽돌깨기")
clock = pygame.time.Clock()

PADDLE_W, PADDLE_H = 110, 14
PADDLE_SPEED = 8
paddle = pygame.Rect(0, 0, PADDLE_W, PADDLE_H)
paddle.centerx = W // 2
paddle.y = H - 50

BALL_R = 8
BALL_SPEED = 5
ball_x = ball_y = ball_vx = ball_vy = 0.0

def reset_ball():
    global ball_x, ball_y, ball_vx, ball_vy
    ball_x, ball_y = W / 2, H / 2
    ball_vx = BALL_SPEED * 0.7
    ball_vy = -BALL_SPEED

reset_ball()

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

    keys = pygame.key.get_pressed()
    if keys[pygame.K_LEFT]:
        paddle.x -= PADDLE_SPEED
    if keys[pygame.K_RIGHT]:
        paddle.x += PADDLE_SPEED
    if paddle.left < 0:
        paddle.left = 0
    if paddle.right > W:
        paddle.right = W

    ball_x += ball_vx
    ball_y += ball_vy

    if ball_x - BALL_R < 0:
        ball_x = BALL_R
        ball_vx = -ball_vx
    if ball_x + BALL_R > W:
        ball_x = W - BALL_R
        ball_vx = -ball_vx
    if ball_y - BALL_R < 0:
        ball_y = BALL_R
        ball_vy = -ball_vy
    if ball_y - BALL_R > H:
        reset_ball()

    # 공을 감싸는 사각형을 만들어서 충돌 검사에 쓴다
    ball_rect = pygame.Rect(ball_x - BALL_R, ball_y - BALL_R, BALL_R * 2, BALL_R * 2)

    # 패들에 맞았고, 공이 '내려오는 중'일 때만 튕긴다
    if ball_rect.colliderect(paddle) and ball_vy > 0:
        ball_vy = -abs(ball_vy)                # 무조건 위쪽으로
        offset = (ball_x - paddle.centerx) / (PADDLE_W / 2)   # -1 ~ +1
        ball_vx = BALL_SPEED * offset          # 맞은 위치가 각도를 정한다

    screen.fill((18, 20, 32))
    pygame.draw.rect(screen, (90, 200, 255), paddle, border_radius=7)
    pygame.draw.circle(screen, (255, 230, 120), (int(ball_x), int(ball_y)), BALL_R)

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

pygame.quit()

📐 각도 완전 정복

이 부분이 이번 프로젝트에서 가장 중요한 내용입니다. 여기를 확실히 이해하면 다음 프로젝트인 탱크 게임이 훨씬 쉬워집니다. 천천히 읽으세요.

1단계 — "각도"는 사실 vx와 vy의 비율이다

우리는 코드 어디에도 "각도"라는 변수를 만들지 않았습니다. 그런데도 공은 여러 각도로 날아갑니다. 왜일까요? vx와 vy 두 숫자의 비율이 곧 각도이기 때문입니다.

vx = 4 (가로로 4칸) vy = −3 (세로로 3칸 위) 실제로 공이 가는 방향 θ

공은 매 프레임 가로로 vx칸, 세로로 vy칸 움직입니다. 그 두 움직임을 합치면 대각선 방향이 나오죠. 이 대각선이 기울어진 정도가 바로 각도입니다. 그래서 vx와 vy를 바꾸는 것 = 각도를 바꾸는 것입니다.

vxvy공이 가는 방향
0−5똑바로 위 (각도 90°)
5−5오른쪽 위 대각선 (45°)
50똑바로 오른쪽 (0°)
−5−5왼쪽 위 대각선 (135°)
1−9거의 수직에 가까운 위쪽 (약 84°)
진짜 각도가 몇 도인지 궁금하다면? 파이썬으로 직접 계산할 수 있습니다. 게임에 꼭 필요하진 않지만, 한 번 찍어보면 감이 확 옵니다.
import math
angle = math.degrees(math.atan2(-ball_vy, ball_vx))
print(f"지금 각도: {angle:.1f}도")
atan2는 "가로로 얼마, 세로로 얼마 갔는지"를 넣으면 각도를 되돌려주는 함수입니다. -ball_vy처럼 부호를 뒤집는 이유는, 파이게임의 y축은 아래가 +인데 수학의 각도는 위쪽이 +이기 때문입니다.

2단계 — 패들의 어디에 맞았는지를 −1 ~ +1 숫자로 바꾸기

이제 "맞은 위치"를 각도로 연결해야 합니다. 문제는 패들 위의 위치가 픽셀 값(예: 283, 340)이라 그대로 쓸 수 없다는 것입니다. 그래서 두 단계로 바꿉니다.

offset = (ball_x - paddle.centerx) / (PADDLE_W / 2)
         └────── ① ──────┘   └───── ② ─────┘

②번 과정을 정규화라고 부릅니다. 이렇게 해두면 패들 크기를 아무리 바꿔도 코드를 고칠 필요가 없습니다. 패들이 300픽셀로 커져도 끝은 여전히 −1과 +1이니까요.

3단계 — −1~+1을 실제 각도로

이제 ball_vx = BALL_SPEED * offset 한 줄로 각도가 정해집니다. 패들 어디에 맞느냐에 따라 이렇게 달라집니다.

중심 offset −1 왼쪽 끝 −0.5 0 한가운데 = 수직 +0.5 offset +1 오른쪽 끝
맞은 위치offsetball_vxball_vy날아가는 방향
왼쪽 끝−15 × (−1) = −5−5왼쪽 위 45°
왼쪽 절반쯤−0.55 × (−0.5) = −2.5−5살짝 왼쪽 위
한가운데05 × 0 = 0−5똑바로 위 (90°)
오른쪽 절반쯤+0.5+2.5−5살짝 오른쪽 위
오른쪽 끝+1+5−5오른쪽 위 45°

vy는 항상 −5로 같고, vx만 −5에서 +5까지 변합니다. 그런데도 결과적으로 부채꼴 모양으로 각도가 쫙 펼쳐지죠. 이것이 "비율이 각도를 만든다"는 뜻입니다.

실제 벽돌깨기와 진짜 물리의 차이
현실에서 공이 평평한 판에 부딪히면 무조건 입사각 = 반사각입니다 (4번 챕터의 벽처럼). 그런데 여기서는 일부러 그 규칙을 깼습니다. 맞은 위치로 각도를 정하게 만든 거죠.

왜 그럴까요? 물리적으로 정확하면 게임이 재미없기 때문입니다. 규칙대로면 공이 늘 같은 각도로만 오가서 플레이어가 할 수 있는 게 없습니다. 위치로 각도를 조절할 수 있으면 "왼쪽 끝으로 받아서 저 구석 벽돌을 노려야지" 같은 전략이 생깁니다. 실제 상용 벽돌깨기 게임들도 전부 이 방식을 씁니다.

4단계 — 이게 탱크 게임에서 어떻게 쓰일까?

벽돌깨기에서는 각도를 "맞은 위치"로 간접적으로 정했습니다. 탱크 게임에서는 각도를 플레이어가 직접 정합니다. 그때 필요한 게 sincos입니다.

# 벽돌깨기 (지금): 위치 → 속도
ball_vx = BALL_SPEED * offset
ball_vy = -BALL_SPEED

# 탱크 게임 (다음): 각도 + 힘 → 속도
vx = 힘 * math.cos(math.radians(각도))
vy = -힘 * math.sin(math.radians(각도))

모양이 비슷하죠? "어떤 숫자로부터 vx, vy를 만들어낸다"는 구조가 똑같습니다. cossin은 "각도를 넣으면 가로 비율·세로 비율을 알려주는 함수"일 뿐입니다. 지금 offset이 하던 일을, 탱크 게임에서는 cos/sin이 더 정확하게 해주는 것입니다.


and ball_vy > 0이 왜 필요할까요? 이 조건이 없으면, 공이 패들을 스치며 올라가는 도중에도 충돌로 판정되어 방향이 계속 뒤집히며 공이 패들 안에 갇힙니다. "내려오는 중일 때만 튕긴다"는 조건이 이 문제를 막아줍니다.
직접 해보기 패들의 왼쪽 끝, 한가운데, 오른쪽 끝으로 각각 공을 받아쳐서 튕기는 방향이 정말 달라지는지 확인하세요. 그다음 ball_vx = BALL_SPEED * offset에서 BALL_SPEEDBALL_SPEED * 2로 바꾸면 어떻게 되나요?

6벽돌 만들고 부수기

벽돌은 이중 for문으로 만든 격자입니다. 뱀게임 1번 챕터에서 책상을 격자로 만들던 것과 완전히 같은 방식이고, 벽돌 하나하나는 {"rect": 위치, "color": 색} 딕셔너리로 관리합니다.

import pygame

pygame.init()
W, H = 640, 700
screen = pygame.display.set_mode((W, H))
pygame.display.set_caption("벽돌깨기")
clock = pygame.time.Clock()
font = pygame.font.SysFont("malgungothic", 22)

PADDLE_W, PADDLE_H = 110, 14
PADDLE_SPEED = 8
paddle = pygame.Rect(0, 0, PADDLE_W, PADDLE_H)
paddle.centerx = W // 2
paddle.y = H - 50

BALL_R = 8
BALL_SPEED = 5
ball_x = ball_y = ball_vx = ball_vy = 0.0

BRICK_COLS, BRICK_ROWS = 8, 5                  # 가로 8개, 세로 5줄
BRICK_W, BRICK_H = 70, 26
BRICK_GAP = 6                                  # 벽돌 사이 간격
BRICK_TOP = 70                                 # 첫 줄이 시작하는 높이
BRICK_COLORS = [
    (255, 110, 110), (255, 170, 90), (255, 230, 120),
    (130, 220, 150), (120, 180, 255),
]

def make_bricks():
    bricks = []
    total_w = BRICK_COLS * BRICK_W + (BRICK_COLS - 1) * BRICK_GAP
    left = (W - total_w) // 2                  # 벽돌 전체를 화면 가운데로
    for row in range(BRICK_ROWS):
        for col in range(BRICK_COLS):
            x = left + col * (BRICK_W + BRICK_GAP)
            y = BRICK_TOP + row * (BRICK_H + BRICK_GAP)
            bricks.append({
                "rect": pygame.Rect(x, y, BRICK_W, BRICK_H),
                "color": BRICK_COLORS[row % len(BRICK_COLORS)],   # 줄마다 다른 색
            })
    return bricks

bricks = make_bricks()
score = 0

def reset_ball():
    global ball_x, ball_y, ball_vx, ball_vy
    ball_x, ball_y = W / 2, H / 2
    ball_vx = BALL_SPEED * 0.7
    ball_vy = -BALL_SPEED

reset_ball()

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

    keys = pygame.key.get_pressed()
    if keys[pygame.K_LEFT]:
        paddle.x -= PADDLE_SPEED
    if keys[pygame.K_RIGHT]:
        paddle.x += PADDLE_SPEED
    if paddle.left < 0:
        paddle.left = 0
    if paddle.right > W:
        paddle.right = W

    ball_x += ball_vx
    ball_y += ball_vy

    if ball_x - BALL_R < 0:
        ball_x = BALL_R
        ball_vx = -ball_vx
    if ball_x + BALL_R > W:
        ball_x = W - BALL_R
        ball_vx = -ball_vx
    if ball_y - BALL_R < 0:
        ball_y = BALL_R
        ball_vy = -ball_vy
    if ball_y - BALL_R > H:
        reset_ball()

    ball_rect = pygame.Rect(ball_x - BALL_R, ball_y - BALL_R, BALL_R * 2, BALL_R * 2)

    if ball_rect.colliderect(paddle) and ball_vy > 0:
        ball_vy = -abs(ball_vy)
        offset = (ball_x - paddle.centerx) / (PADDLE_W / 2)
        ball_vx = BALL_SPEED * offset

    # 벽돌에 맞으면 그 벽돌을 없애고 세로 방향을 뒤집는다
    for b in bricks:
        if ball_rect.colliderect(b["rect"]):
            bricks.remove(b)
            ball_vy = -ball_vy
            score += 10
            break                              # 반복 중 리스트를 바꿨으니 즉시 빠져나온다

    screen.fill((18, 20, 32))
    for b in bricks:
        pygame.draw.rect(screen, b["color"], b["rect"], border_radius=5)
    pygame.draw.rect(screen, (90, 200, 255), paddle, border_radius=7)
    pygame.draw.circle(screen, (255, 230, 120), (int(ball_x), int(ball_y)), BALL_R)
    screen.blit(font.render(f"점수 {score}", True, (235, 240, 250)), (16, 20))

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

pygame.quit()
break를 빼면 안 됩니다! for b in bricks:로 리스트를 돌면서 bricks.remove(b)로 그 리스트를 바꾸고 있습니다. 이건 파이썬에서 매우 위험한 동작이라, 빼먹으면 벽돌을 건너뛰거나 오류가 납니다. break로 "한 프레임에 벽돌 하나만 부수고 즉시 빠져나오기"가 가장 간단한 해결책입니다.
BRICK_COLORS[row % len(BRICK_COLORS)]에서 %(나머지)를 쓰면 색이 부족해도 자동으로 처음 색부터 다시 반복됩니다. 줄 수를 10줄로 늘려도 색 목록을 고칠 필요가 없습니다.
직접 해보기 BRICK_ROWS를 3이나 8로 바꿔보세요. break를 지우고 실행하면 무슨 일이 생기는지도 꼭 확인해보세요 (오류 메시지를 직접 읽어보는 게 중요합니다).

7목숨·승패·다시 시작 — 게임 완성

게임이 되려면 끝이 있어야 합니다. 지금 상태가 진행 중인지, 이겼는지, 졌는지를 state 변수 하나로 관리합니다. 뱀게임 8번 챕터의 reset()과 같은 방식입니다.

import pygame

pygame.init()
W, H = 640, 700
screen = pygame.display.set_mode((W, H))
pygame.display.set_caption("벽돌깨기")
clock = pygame.time.Clock()
font = pygame.font.SysFont("malgungothic", 22)
big_font = pygame.font.SysFont("malgungothic", 40, bold=True)

PADDLE_W, PADDLE_H = 110, 14
PADDLE_SPEED = 8
paddle = pygame.Rect(0, 0, PADDLE_W, PADDLE_H)
paddle.centerx = W // 2
paddle.y = H - 50

BALL_R = 8
BALL_SPEED = 5
ball_x = ball_y = ball_vx = ball_vy = 0.0

BRICK_COLS, BRICK_ROWS = 8, 5
BRICK_W, BRICK_H = 70, 26
BRICK_GAP = 6
BRICK_TOP = 70
BRICK_COLORS = [
    (255, 110, 110), (255, 170, 90), (255, 230, 120),
    (130, 220, 150), (120, 180, 255),
]

def make_bricks():
    bricks = []
    total_w = BRICK_COLS * BRICK_W + (BRICK_COLS - 1) * BRICK_GAP
    left = (W - total_w) // 2
    for row in range(BRICK_ROWS):
        for col in range(BRICK_COLS):
            x = left + col * (BRICK_W + BRICK_GAP)
            y = BRICK_TOP + row * (BRICK_H + BRICK_GAP)
            bricks.append({
                "rect": pygame.Rect(x, y, BRICK_W, BRICK_H),
                "color": BRICK_COLORS[row % len(BRICK_COLORS)],
            })
    return bricks

bricks = []
score = 0
lives = 3
state = "play"                                 # "play" / "over" / "clear"

def reset_ball():
    global ball_x, ball_y, ball_vx, ball_vy
    ball_x, ball_y = W / 2, H / 2
    ball_vx = BALL_SPEED * 0.7
    ball_vy = -BALL_SPEED

def reset_game():                              # 게임 전체를 처음 상태로
    global bricks, score, lives, state
    bricks = make_bricks()
    score = 0
    lives = 3
    state = "play"
    paddle.centerx = W // 2
    reset_ball()

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 and state != "play":
            reset_game()

    keys = pygame.key.get_pressed()
    if keys[pygame.K_LEFT]:
        paddle.x -= PADDLE_SPEED
    if keys[pygame.K_RIGHT]:
        paddle.x += PADDLE_SPEED
    if paddle.left < 0:
        paddle.left = 0
    if paddle.right > W:
        paddle.right = W

    if state == "play":                        # 진행 중일 때만 공이 움직인다
        ball_x += ball_vx
        ball_y += ball_vy

        if ball_x - BALL_R < 0:
            ball_x = BALL_R
            ball_vx = -ball_vx
        if ball_x + BALL_R > W:
            ball_x = W - BALL_R
            ball_vx = -ball_vx
        if ball_y - BALL_R < 0:
            ball_y = BALL_R
            ball_vy = -ball_vy

        ball_rect = pygame.Rect(ball_x - BALL_R, ball_y - BALL_R, BALL_R * 2, BALL_R * 2)

        if ball_rect.colliderect(paddle) and ball_vy > 0:
            ball_vy = -abs(ball_vy)
            offset = (ball_x - paddle.centerx) / (PADDLE_W / 2)
            ball_vx = BALL_SPEED * offset

        for b in bricks:
            if ball_rect.colliderect(b["rect"]):
                bricks.remove(b)
                ball_vy = -ball_vy
                score += 10
                break

        # 바닥으로 빠지면 목숨 하나를 잃는다
        if ball_y - BALL_R > H:
            lives -= 1
            if lives <= 0:
                state = "over"
            else:
                reset_ball()

        # 벽돌을 모두 없애면 승리
        if not bricks:
            state = "clear"

    screen.fill((18, 20, 32))
    for b in bricks:
        pygame.draw.rect(screen, b["color"], b["rect"], border_radius=5)
    pygame.draw.rect(screen, (90, 200, 255), paddle, border_radius=7)
    pygame.draw.circle(screen, (255, 230, 120), (int(ball_x), int(ball_y)), BALL_R)
    screen.blit(font.render(f"점수 {score}", True, (235, 240, 250)), (16, 20))
    lives_img = font.render(f"목숨 {lives}", True, (235, 240, 250))
    screen.blit(lives_img, (W - lives_img.get_width() - 16, 20))

    if state != "play":
        msg = "게임 오버" if state == "over" else "클리어!"
        msg_img = big_font.render(msg, True, (235, 240, 250))
        sub_img = font.render("스페이스를 누르면 다시 시작", True, (235, 240, 250))
        screen.blit(msg_img, msg_img.get_rect(center=(W // 2, H // 2 - 20)))
        screen.blit(sub_img, sub_img.get_rect(center=(W // 2, H // 2 + 30)))

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

pygame.quit()

여기까지 하면 완성된 벽돌깨기입니다. 공을 놓치면 목숨이 줄고, 벽돌을 다 부수면 클리어되고, 스페이스로 다시 시작할 수 있습니다.

패들 조작은 if state == "play": 밖에 두었습니다. 게임이 끝난 뒤에도 패들은 움직일 수 있게 한 것인데, 안쪽으로 옮기면 게임오버 화면에서 패들이 멈춥니다. 어느 쪽이 더 좋은지는 직접 해보고 정하세요.
직접 해보기
  • 벽돌을 부술 때마다 공이 조금씩 빨라지게 만들기 (힌트: ball_vy1.02를 곱해보기)
  • 줄마다 점수를 다르게 주기 (위쪽 줄일수록 높은 점수)
  • 최고 점수를 기억했다가 화면에 같이 보여주기

8중력 실험 — 다음 프로젝트(탱크 게임) 준비 ★

벽돌깨기는 완성했습니다. 이번 챕터는 게임을 더 좋게 만드는 게 아니라, 다음에 만들 탱크 게임을 위한 실험입니다.

지금 공은 속도가 한번 정해지면 그대로 직진합니다. 여기에 딱 한 줄을 추가하면 공이 포물선을 그리기 시작합니다.

GRAVITY = 0.12                                 # 한 프레임마다 아래 방향 속도가 이만큼 늘어난다

    if state == "play":
        ball_vy += GRAVITY                     ← 이 한 줄이 전부입니다
        ball_x += ball_vx
        ball_y += ball_vy

이 한 줄이 왜 포물선을 만들까요?

ball_vy는 "세로 속도"입니다. 여기에 매 프레임 조금씩 더하면:

프레임ball_vy공의 움직임
0-5.0빠르게 위로
20-2.6느리게 위로
420.0가장 높은 지점 (잠깐 멈춤)
60+2.2천천히 아래로
100+7.0빠르게 아래로

위로 올라갈수록 느려지고, 정점에서 멈췄다가, 내려오면서 점점 빨라집니다. 이것이 바로 우리가 공을 던졌을 때 실제로 일어나는 일이고, 수학에서 배우는 포물선입니다.

탱크 게임 미리보기: 다음 프로젝트에서 만들 대포알이 바로 이 원리로 날아갑니다. 거기서는 각도와 힘으로 처음 속도를 정하는 것만 추가됩니다.
vx = 힘 * math.cos(math.radians(각도))
vy = -힘 * math.sin(math.radians(각도))
# 그다음은 벽돌깨기와 똑같이:
vy += GRAVITY
x += vx
y += vy
벽돌깨기에서 배운 x += vx, y += vy, vy += GRAVITY가 그대로 쓰입니다.
직접 해보기
  • GRAVITY를 0.02 / 0.12 / 0.5로 바꿔가며 궤적이 어떻게 달라지는지 관찰하세요.
  • 중력을 넣으면 벽돌깨기가 훨씬 어려워집니다. 실험이 끝나면 그 줄 앞에 #을 붙여 꺼두세요.
  • 공이 바닥에 닿았을 때 튕기되 ball_vy = -ball_vy * 0.8처럼 조금 줄여보세요. 점점 낮게 튀다가 멈추는 진짜 공 같은 움직임이 됩니다.