완성된 모습 — 6번 챕터까지 만들면 이렇게 동작합니다
각 챕터는 이전 챕터의 전체 코드 + 새로 추가된 부분입니다.  파란색으로 밑칠된 줄 이 이번 챕터에서 새로 생긴 부분입니다.
이 게임에서 배우는 것: 뱀게임은 칸(격자) 단위로 움직였지만, 이번에는 픽셀 단위로 부드럽게 움직입니다. 그리고 운석 여러 개를 리스트로 한꺼번에 관리하는 법을 익힙니다. 다음 게임(벽돌깨기)에서 공을 다룰 때 그대로 쓰이는 기술입니다.

1화면과 플레이어 만들기

피할 사람(플레이어)을 먼저 화면 아래쪽에 놓습니다. 뱀게임에서는 뱀의 위치를 (3, 5)처럼 칸 번호로 관리했지만, 이번에는 pygame.Rect픽셀 위치를 직접 관리합니다.

import pygame

pygame.init()
W, H = 480, 640                                # 창의 가로, 세로
screen = pygame.display.set_mode((W, H))
pygame.display.set_caption("피하기 게임")
clock = pygame.time.Clock()

PLAYER_W, PLAYER_H = 44, 44
player = pygame.Rect(0, 0, PLAYER_W, PLAYER_H) # 일단 (0,0)에 만들고
player.centerx = W // 2                        # 가로는 화면 가운데로
player.bottom = H - 20                         # 바닥에서 20픽셀 위로

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

    screen.fill((24, 26, 38))                  # 어두운 배경
    pygame.draw.rect(screen, (120, 220, 160), player, border_radius=8)

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

pygame.quit()
Rect는 위치를 여러 이름으로 지정할 수 있습니다. x(왼쪽), centerx(가로 중심), bottom(아래쪽 끝) 중에 편한 것을 쓰면 나머지는 파이게임이 알아서 계산해줍니다. "바닥에서 20픽셀 위"를 bottom = H - 20 한 줄로 쓸 수 있는 이유입니다.
직접 해보기 player.bottom = H - 20player.top = 0으로 바꾸면 어디에 그려지나요? PLAYER_W를 120으로 키우면 어떻게 되나요?

2부드럽게 움직이기 — 뱀게임과 다른 점 ★

뱀게임에서는 방향키를 누른 순간(KEYDOWN 이벤트) 방향만 바꾸고, 실제 이동은 타이머가 했습니다. 그런데 피하기 게임은 키를 누르고 있는 동안 계속 움직여야 합니다. 이럴 때 쓰는 것이 pygame.key.get_pressed()입니다.

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

pygame.init()
W, H = 480, 640
screen = pygame.display.set_mode((W, H))
pygame.display.set_caption("피하기 게임")
clock = pygame.time.Clock()

PLAYER_W, PLAYER_H = 44, 44
PLAYER_SPEED = 6                               # 한 프레임에 6픽셀씩
player = pygame.Rect(0, 0, PLAYER_W, PLAYER_H)
player.centerx = W // 2
player.bottom = H - 20

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]:
        player.x -= PLAYER_SPEED
    if keys[pygame.K_RIGHT]:
        player.x += PLAYER_SPEED

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

    screen.fill((24, 26, 38))
    pygame.draw.rect(screen, (120, 220, 160), player, border_radius=8)

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

pygame.quit()

초당 60프레임이므로, 키를 1초 누르고 있으면 6 × 60 = 360픽셀을 이동합니다. 뱀게임처럼 뚝뚝 끊기지 않고 부드럽게 미끄러지는 느낌이 나는 이유입니다.

화면 밖 막기는 "일단 움직이고, 너무 갔으면 되돌리기" 방식입니다. 이동 전에 미리 계산해서 막는 것보다 코드가 짧고, 속도를 바꿔도 그대로 작동합니다.
직접 해보기 화면 밖 막기 4줄을 잠시 지우고 실행해보세요. 플레이어가 어떻게 되나요? 그리고 if 대신 elif를 쓰면 좌우 키를 동시에 눌렀을 때 어떻게 달라지는지 실험해보세요.

3운석 하나 떨어뜨리기 — 실수 좌표의 필요성 ★

이제 위에서 운석이 떨어집니다. 여기서 중요한 함정을 하나 만납니다.

Rect는 정수만 저장합니다! rect.y += 3.5를 해도 실제로는 소수점이 잘려서 3만 더해집니다. 매 프레임 0.5씩 손해를 보니, 속도를 3.5로 정해도 실제로는 3의 속도로 움직입니다. 게다가 나중에 속도를 조금씩 올릴 때(6번 챕터) 3.9까지 올려도 전혀 빨라지지 않는 이상한 버그가 생깁니다.

그래서 위치는 실수(float) 변수로 따로 관리하고, 그릴 때만 int()로 바꿔서 사각형을 만듭니다. 이 방법은 다음 게임인 벽돌깨기에서 공을 다룰 때도 똑같이 씁니다.

import pygame
import random

pygame.init()
W, H = 480, 640
screen = pygame.display.set_mode((W, H))
pygame.display.set_caption("피하기 게임")
clock = pygame.time.Clock()

PLAYER_W, PLAYER_H = 44, 44
PLAYER_SPEED = 6
player = pygame.Rect(0, 0, PLAYER_W, PLAYER_H)
player.centerx = W // 2
player.bottom = H - 20

ROCK_W, ROCK_H = 36, 36
rock_x = float(random.randint(0, W - ROCK_W))  # 가로 위치는 무작위로
rock_y = -float(ROCK_H)                        # 화면 위쪽 바깥에서 시작
fall_speed = 3.0                               # 한 프레임에 3픽셀씩 낙하

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]:
        player.x -= PLAYER_SPEED
    if keys[pygame.K_RIGHT]:
        player.x += PLAYER_SPEED
    if player.left < 0:
        player.left = 0
    if player.right > W:
        player.right = W

    rock_y += fall_speed                       # 아래로 떨어뜨리기

    if rock_y > H:                             # 바닥을 지나갔으면
        rock_x = float(random.randint(0, W - ROCK_W))
        rock_y = -float(ROCK_H)                # 다시 위에서 시작

    screen.fill((24, 26, 38))
    # 실수 좌표를 int로 바꿔서 그릴 사각형을 만든다
    rock = pygame.Rect(int(rock_x), int(rock_y), ROCK_W, ROCK_H)
    pygame.draw.rect(screen, (255, 130, 110), rock, border_radius=6)
    pygame.draw.rect(screen, (120, 220, 160), player, border_radius=8)

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

pygame.quit()

random.randint(0, W - ROCK_W)에서 W - ROCK_W를 쓰는 이유는, 운석의 오른쪽 끝이 화면을 넘지 않게 하기 위해서입니다. 그냥 randint(0, W)로 하면 운석이 화면 밖에 반쯤 걸쳐서 나타납니다.

직접 해보기 fall_speed를 0.5로 바꿔 아주 느리게 만들어보세요. 그다음 실수 관리를 일부러 없애고 rock = pygame.Rect(...)를 만든 뒤 rock.y += 0.5로 해보세요. 운석이 아예 안 움직이는 것을 확인할 수 있습니다 (0.5가 매번 잘려서 0이 되기 때문).

4운석 여러 개 — 리스트로 한꺼번에 관리 ★

운석을 6개로 늘리려고 rock_x1, rock_x2, rock_x3… 이렇게 변수를 늘리면 안 됩니다. 운석 하나를 딕셔너리로 만들고, 그것들을 리스트에 담습니다. 뱀게임에서 뱀의 몸통을 리스트로 관리하던 것과 같은 방식입니다.

운석 하나 = {"x": 가로위치, "y": 세로위치, "speed": 개인 속도}
운석들    = [운석1, 운석2, 운석3, 운석4, 운석5, 운석6]
import pygame
import random

pygame.init()
W, H = 480, 640
screen = pygame.display.set_mode((W, H))
pygame.display.set_caption("피하기 게임")
clock = pygame.time.Clock()

PLAYER_W, PLAYER_H = 44, 44
PLAYER_SPEED = 6
player = pygame.Rect(0, 0, PLAYER_W, PLAYER_H)
player.centerx = W // 2
player.bottom = H - 20

ROCK_W, ROCK_H = 36, 36
ROCK_COUNT = 6                                 # 운석 개수
fall_speed = 3.0

def new_rock():
    # 운석 하나를 화면 위쪽 어딘가에서 새로 만든다
    return {
        "x": float(random.randint(0, W - ROCK_W)),
        "y": float(random.randint(-H, -ROCK_H)),  # 위쪽 바깥 여기저기에 흩뿌리기
        "speed": random.uniform(0.7, 1.4),        # 운석마다 조금씩 다른 속도
    }

def rock_rect(r):
    # 실수 좌표로 충돌/그리기용 Rect를 만든다
    return pygame.Rect(int(r["x"]), int(r["y"]), ROCK_W, ROCK_H)

rocks = [new_rock() for _ in range(ROCK_COUNT)]   # 운석 6개를 리스트로

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]:
        player.x -= PLAYER_SPEED
    if keys[pygame.K_RIGHT]:
        player.x += PLAYER_SPEED
    if player.left < 0:
        player.left = 0
    if player.right > W:
        player.right = W

    # 리스트에 담긴 운석을 하나씩 꺼내서 똑같은 처리를 반복한다
    for r in rocks:
        r["y"] += fall_speed * r["speed"]      # 개인 속도를 곱해 저마다 다른 빠르기로

        if r["y"] > H:
            r["x"] = float(random.randint(0, W - ROCK_W))
            r["y"] = -float(ROCK_H)

    screen.fill((24, 26, 38))
    for r in rocks:
        pygame.draw.rect(screen, (255, 130, 110), rock_rect(r), border_radius=6)
    pygame.draw.rect(screen, (120, 220, 160), player, border_radius=8)

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

pygame.quit()

왜 운석마다 다른 속도를 줄까요?

전부 같은 속도로 떨어지면 운석들이 한 줄로 나란히 내려와서 피하기가 너무 쉽거나 단조롭습니다. random.uniform(0.7, 1.4)로 개인 속도를 주면 어떤 건 빠르게, 어떤 건 느리게 내려와서 훨씬 긴장감이 생깁니다.

[new_rock() for _ in range(ROCK_COUNT)]리스트 컴프리헨션입니다. new_rock()을 6번 호출해 리스트로 만드는 것으로, 아래 코드와 똑같습니다.
rocks = []
for _ in range(ROCK_COUNT):
    rocks.append(new_rock())
변수 이름 _는 "이 값은 안 쓸 거예요"라는 관습적인 표시입니다.
직접 해보기 ROCK_COUNT를 20으로 올려보세요. 코드를 한 줄도 더 고치지 않아도 운석이 20개가 됩니다 — 이것이 리스트로 관리하는 이유입니다. random.uniform(0.7, 1.4)(1.0, 1.0)으로 바꾸면 어떤 느낌이 되나요?

5충돌 판정과 게임 오버

운석과 플레이어가 겹쳤는지는 colliderect() 하나로 확인합니다. 두 사각형이 조금이라도 겹치면 True를 돌려줍니다.

그리고 게임이 끝났는지를 기억할 state 변수를 만듭니다. 뱀게임의 game_over와 같은 역할이지만, 나중에 "승리" 같은 상태를 추가하기 쉽도록 글자로 관리합니다.

import pygame
import random

pygame.init()
W, H = 480, 640
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)

PLAYER_W, PLAYER_H = 44, 44
PLAYER_SPEED = 6
player = pygame.Rect(0, 0, PLAYER_W, PLAYER_H)
player.centerx = W // 2
player.bottom = H - 20

ROCK_W, ROCK_H = 36, 36
ROCK_COUNT = 6
fall_speed = 3.0
state = "play"                                 # "play"(진행 중) / "over"(게임 오버)

def new_rock():
    return {
        "x": float(random.randint(0, W - ROCK_W)),
        "y": float(random.randint(-H, -ROCK_H)),
        "speed": random.uniform(0.7, 1.4),
    }

def rock_rect(r):
    return pygame.Rect(int(r["x"]), int(r["y"]), ROCK_W, ROCK_H)

rocks = [new_rock() for _ in range(ROCK_COUNT)]

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]:
        player.x -= PLAYER_SPEED
    if keys[pygame.K_RIGHT]:
        player.x += PLAYER_SPEED
    if player.left < 0:
        player.left = 0
    if player.right > W:
        player.right = W

    if state == "play":                        # 진행 중일 때만 운석이 움직인다
        for r in rocks:
            r["y"] += fall_speed * r["speed"]

            if r["y"] > H:
                r["x"] = float(random.randint(0, W - ROCK_W))
                r["y"] = -float(ROCK_H)

            if rock_rect(r).colliderect(player):   # 부딪혔다면
                state = "over"

    screen.fill((24, 26, 38))
    for r in rocks:
        pygame.draw.rect(screen, (255, 130, 110), rock_rect(r), border_radius=6)
    pygame.draw.rect(screen, (120, 220, 160), player, border_radius=8)

    if state == "over":
        msg = big_font.render("게임 오버", True, (235, 240, 250))
        screen.blit(msg, msg.get_rect(center=(W // 2, H // 2)))

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

pygame.quit()
플레이어 조작은 일부러 if state == "play": 밖에 두었습니다. 게임이 끝난 뒤에도 플레이어는 움직일 수 있습니다. 안쪽으로 옮기면 게임오버 화면에서 완전히 멈추게 됩니다. 어느 쪽이 좋은지 직접 해보고 정하세요.
직접 해보기 colliderect는 사각형끼리 겹침을 봅니다. 그래서 실제 그림이 둥글어도 모서리 근처에서 억울하게 죽는 느낌이 납니다. 운석의 충돌 사각형을 조금 작게 만들어(rock_rect(r).inflate(-8, -8)) 억울함을 줄여보세요.

6점수·난이도 상승·다시 시작 — 게임 완성

마지막으로 세 가지를 넣으면 게임이 완성됩니다.

import pygame
import random

pygame.init()
W, H = 480, 640
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)

PLAYER_W, PLAYER_H = 44, 44
PLAYER_SPEED = 6
player = pygame.Rect(0, 0, PLAYER_W, PLAYER_H)
player.centerx = W // 2
player.bottom = H - 20

ROCK_W, ROCK_H = 36, 36
ROCK_COUNT = 6

rocks = []
score = 0
fall_speed = 3.0
state = "play"

def new_rock():
    return {
        "x": float(random.randint(0, W - ROCK_W)),
        "y": float(random.randint(-H, -ROCK_H)),
        "speed": random.uniform(0.7, 1.4),
    }

def rock_rect(r):
    return pygame.Rect(int(r["x"]), int(r["y"]), ROCK_W, ROCK_H)

def reset_game():                              # 게임을 처음 상태로 되돌린다
    global rocks, score, fall_speed, state
    rocks = [new_rock() for _ in range(ROCK_COUNT)]
    score = 0
    fall_speed = 3.0
    state = "play"
    player.centerx = W // 2

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]:
        player.x -= PLAYER_SPEED
    if keys[pygame.K_RIGHT]:
        player.x += PLAYER_SPEED
    if player.left < 0:
        player.left = 0
    if player.right > W:
        player.right = W

    if state == "play":
        for r in rocks:
            r["y"] += fall_speed * r["speed"]

            if r["y"] > H:
                r["x"] = float(random.randint(0, W - ROCK_W))
                r["y"] = -float(ROCK_H)
                score += 10                    # 하나 피했으니 점수 추가

            if rock_rect(r).colliderect(player):
                state = "over"

        fall_speed += 0.002                    # 시간이 갈수록 조금씩 빨라진다

    screen.fill((24, 26, 38))
    for r in rocks:
        pygame.draw.rect(screen, (255, 130, 110), rock_rect(r), border_radius=6)
    pygame.draw.rect(screen, (120, 220, 160), player, border_radius=8)
    screen.blit(font.render(f"점수 {score}", True, (235, 240, 250)), (16, 16))
    screen.blit(font.render(f"속도 {fall_speed:.1f}", True, (235, 240, 250)), (16, 44))

    if state == "over":
        msg = big_font.render("게임 오버", True, (235, 240, 250))
        sub = font.render("스페이스를 누르면 다시 시작", True, (235, 240, 250))
        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()

0.002라는 작은 숫자의 힘

fall_speed += 0.002는 한 프레임에 0.002밖에 안 늘지만, 초당 60프레임이므로 1초에 0.12씩 빨라집니다. 1분이면 약 7.2가 더해져 처음(3.0)의 세 배가 넘습니다. 눈치채지 못할 만큼 천천히 어려워지는 것이 좋은 난이도 설계입니다.

다음 게임(벽돌깨기)에서 이어지는 것들
  • 실수 좌표로 위치를 관리하고 그릴 때만 int()로 바꾸기 → 공의 위치에 그대로 사용
  • get_pressed()로 부드럽게 조작하기 → 패들 조작에 그대로 사용
  • 딕셔너리를 리스트에 담아 관리하기 → 벽돌 40개 관리에 그대로 사용
  • state로 게임 상태 관리, reset_game()으로 재시작 → 구조가 동일
여기서 배운 것이 다음 게임의 재료가 됩니다. 벽돌깨기에서는 여기에 속도(vx, vy)와 각도가 새로 추가됩니다.
더 해보기
  • 최고 점수를 기억했다가 화면에 같이 보여주기 (reset_game()에서 초기화하지 않기)
  • 가끔 초록색 아이템이 떨어지고, 먹으면 점수 +100 주기
  • 위아래로도 움직일 수 있게 만들기 (K_UP, K_DOWN 추가)
  • 운석이 대각선으로 떨어지게 만들기 — 운석 딕셔너리에 "vx"를 추가하면 됩니다. 이게 바로 벽돌깨기에서 배울 속도 벡터의 맛보기입니다.