본문 바로가기

프로그래밍

pygame으로 공 튀기기 만들기

게임으로 써의 기능은 없고 단순히 흰색 공이 벽에 부딪치면 튀어 나가는 동작을 하는 코드로 방향 키를 이용해서 속도를 조절하는 프로그램입니다.

1. 기능
  - 흰색 공이 벽에 부딪히며 이동
  - 위/아래 방향 키로 속도 조절
  - space : 초기 위치 이동, esc : 프로그램 종료
  - 화면 좌측 상단에 현재 속도를 녹색으로 표시

2. 환경
  - 파이썬 버전 : 3.13
  - pygame 2.6.1

3. 동작 영상

 

 

4. 코딩 시 고려한 것들
1) 이동량을 프레임이 아니라 시간에 비례시킨다

 x += dx * speed

이렇게 계산하면 프레임이 흔들릴 때 공의 체감 속도도 같이 흔들립니다.
경과 시간(dt)을 곱하면 프레임 수와 무관하게 일정해집니다.

  dt = clock.tick(FPS) / 1000.0
  x += dx * speed * dt

2) 벽에 부딪히면 파고든 만큼 되밀어 놓는다
이 부분은 글로 설명하기에는 복잡합니다. 공이 눈으로 봤을 때는 연속 동작인 것처럼 보이지만 실제로는 매 프레임에 한 번씩 갱신됩니다. 예로 속도가 600px/s에 60프레임이면 한 프레임에 10픽셀씩 이동합니다. 왼쪽 벽 기준으로 공의 중심 좌표 x는 20(공의 반지름) 이상이어야 정상입니다. 그런데 어느 프레임에 x가 28이었고 왼쪽으로 10픽셀 가면 x는 18이 됩니다. 18은 20보다 작으니, 화면상으로는 공의 일부가 벽을 뚫고 나간 상태가 됩니다. 이 문제를 해결하기 위해서 공의 위치를 그대로 둔 채 방향을 바꾸고 x 값은 공이 벽에 닫는 위치를 지정합니다.
​
또 추가되는 문제가 있는데, 이 프로그램은 방향 키로 속도를 조절한다는 것인데 벽면에 부딪치기 직전에 방향 키를 이용해서 속도 조절 중에는 아래 예와 같은 시나리오가 발생하게 됩니다.

  • 속도 1200px/s(프레임당 20픽셀)로 벽에 부딪쳐 x=8이 됨 -> 12픽셀 파고듦
  • 방향을 오른쪽으로 뒤집음
  • 이때 속도가 60px/s(프레임당 1픽셀)로 떨어짐 -> 다음 프레임 x=9
  • 9는 여전히 20보다 작음 -> 조건이 다시 참 -> 또 뒤집힘 -> 이번엔 왼쪽으로 -> x=8
  • 다시 조건 참 -> 또 뒤집힘 -> x=9 -> ...
if x - BALL_RADIUS < 0:
    x = BALL_RADIUS + (BALL_RADIUS - x)
    dx = -dx

 

위와 같이 코드를 작성해야 두 가지 문제를 해결할 수 있습니다.

3) 모서리 부분에 부딪치면 계속 모서리에만 부딪치는 현상
원인은 기하학에 있습니다. 직사각형 안에서 45도로 반사되는 궤적은 닫힌 주기 운동이 됩니다. 즉 같은 경로를 계속 되밟게 되는데 실제로는 조금씩 넓어지게 됩니다. 이유는 부동소수점 오차 누적에 의한 것이고 만약 오차가 없는 환경이라면 무한 반복 상황이 됩니다.

이 현상은 모서리 부분만이 아니라 0도나 90도에 가까운 방향에 부딪쳐도 벽을 따라 미끄러지는 현상이 발생합니다. 이를 해결하기 위해서 세 가지 방법을 적용합니다.

3.1) 시작 방향을 축에서 15도, 대각선에서 8도 이상 떨어진 각도에서만 뽑는다

def random_direction():
    while True:
        angle = random.uniform(0, 2 * math.pi)
        deg = math.degrees(angle) % 90.0
        if 15.0 < deg < 75.0 and abs(deg - 45.0) > 8.0:
            return normalize_direction(math.cos(angle), math.sin(angle))

 

 

3.2) 벽에 부딪칠 때마다 반사 각도를 ±4도 이내로 아주 조금 흔든다
시작 각도만 제한하면 우연히 주기 궤적에 들어갔을 때 빠져나올 방법이 없습니다. 반사할 때마다 미세하게 흔들어 주면 주기가 만들어지려는 순간마다 끊어집니다.

def jitter_direction(dx, dy):
    angle = math.atan2(dy, dx) + random.uniform(-BOUNCE_JITTER, BOUNCE_JITTER)
    return normalize_direction(math.cos(angle), math.sin(angle))

 

 

3.3) 방향 벡터의 각 성분이 일정 크기 아래로 내려가지 않게 보정한다
흔들다 보면 방향이 축에 가까워질 수 있습니다. 그러면 이번에는 벽을 따라 미끄러지는 모양이 되므로 하한을 둡니다.

if abs(dx) < MIN_AXIS_COMPONENT:
    dx = math.copysign(MIN_AXIS_COMPONENT, dx)

 

5. 전체 코드

"""
bouncing_ball.py

흰색 공이 벽에 부딪히며 움직이는 간단한 pygame 예제.

조작
  위 방향키(UP)    : 속도 증가
  아래 방향키(DOWN): 속도 감소
  스페이스         : 공 위치와 방향 초기화
  ESC              : 종료

화면 좌측 상단에 현재 속도를 녹색으로 표시한다.
"""

import math
import random
import sys

import pygame

# ---------------------------------------------------------------- 설정
WIDTH, HEIGHT = 800, 600          # 창 크기
FPS = 60                          # 목표 프레임
BALL_RADIUS = 20                  # 공 반지름

INITIAL_SPEED = 600.0             # 시작 속도 (픽셀/초)
MIN_SPEED = 0.0                   # 최소 속도
MAX_SPEED = 1200.0                # 최대 속도
SPEED_STEP = 400.0                # 키를 누르고 있을 때 초당 속도 변화량

# 진행 방향이 대각선(45도)이나 수평/수직에 가까우면 공이 모서리만 오가는
# 단조로운 궤적에 갇힌다. 아래 두 값으로 그 상태를 피한다.
MIN_AXIS_COMPONENT = 0.25         # 방향 벡터 성분의 최소 크기 (축에 붙는 것 방지)
BOUNCE_JITTER = 0.07              # 반사할 때 방향에 주는 흔들림 (라디안, 약 4도)

BG_COLOR = (18, 18, 24)           # 배경색
BALL_COLOR = (255, 255, 255)      # 공 색상 (흰색)
TEXT_COLOR = (0, 255, 0)          # 속도 표시 색상 (녹색)


def load_font(size):
    """한글 폰트가 있으면 한글 라벨을, 없으면 영문 라벨을 사용한다."""
    for name in ("malgungothic", "malgun gothic", "nanumgothic", "notosanscjkkr"):
        path = pygame.font.match_font(name)
        if path:
            return pygame.font.Font(path, size), "속도"
    return pygame.font.Font(None, size + 4), "SPEED"


def normalize_direction(dx, dy):
    """단위 벡터로 만들고, 축에 너무 달라붙지 않도록 성분을 보정한다."""
    length = math.hypot(dx, dy)
    if length == 0:
        dx, dy = 1.0, 1.0
        length = math.hypot(dx, dy)
    dx, dy = dx / length, dy / length

    if abs(dx) < MIN_AXIS_COMPONENT:
        dx = math.copysign(MIN_AXIS_COMPONENT, dx if dx else 1.0)
    if abs(dy) < MIN_AXIS_COMPONENT:
        dy = math.copysign(MIN_AXIS_COMPONENT, dy if dy else 1.0)

    length = math.hypot(dx, dy)
    return dx / length, dy / length


def random_direction():
    """축(0/90도)과 대각선(45도)에서 충분히 떨어진 방향 벡터를 반환한다."""
    while True:
        angle = random.uniform(0, 2 * math.pi)
        deg = math.degrees(angle) % 90.0
        if 15.0 < deg < 75.0 and abs(deg - 45.0) > 8.0:
            return normalize_direction(math.cos(angle), math.sin(angle))


def jitter_direction(dx, dy):
    """반사할 때마다 방향을 아주 조금 흔들어 같은 궤적의 반복을 끊는다."""
    angle = math.atan2(dy, dx) + random.uniform(-BOUNCE_JITTER, BOUNCE_JITTER)
    return normalize_direction(math.cos(angle), math.sin(angle))

def main():
    pygame.init()
    screen = pygame.display.set_mode((WIDTH, HEIGHT))
    pygame.display.set_caption("Bouncing Ball")
    clock = pygame.time.Clock()

    font, speed_label = load_font(24)
    help_font = pygame.font.Font(None, 20)

    # 공 상태
    x, y = WIDTH / 2.0, HEIGHT / 2.0
    dx, dy = random_direction()
    speed = INITIAL_SPEED

    running = True
    while running:
        dt = clock.tick(FPS) / 1000.0     # 경과 시간(초)

        # ------------------------------------------------------ 입력
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                running = False
            elif event.type == pygame.KEYDOWN:
                if event.key == pygame.K_ESCAPE:
                    running = False
                elif event.key == pygame.K_SPACE:
                    x, y = WIDTH / 2.0, HEIGHT / 2.0
                    dx, dy = random_direction()
                    speed = INITIAL_SPEED

        keys = pygame.key.get_pressed()
        if keys[pygame.K_UP]:
            speed += SPEED_STEP * dt
        if keys[pygame.K_DOWN]:
            speed -= SPEED_STEP * dt
        speed = max(MIN_SPEED, min(MAX_SPEED, speed))

        # ------------------------------------------------------ 이동
        x += dx * speed * dt
        y += dy * speed * dt

        # ------------------------------------------------- 벽 충돌 처리
        # 파고든 만큼 되돌려 놓아야 벽에 들러붙지 않는다
        bounced = False

        if x - BALL_RADIUS < 0:
            x = BALL_RADIUS + (BALL_RADIUS - x)
            dx = -dx
            bounced = True
        elif x + BALL_RADIUS > WIDTH:
            x = WIDTH - BALL_RADIUS - (x + BALL_RADIUS - WIDTH)
            dx = -dx
            bounced = True

        if y - BALL_RADIUS < 0:
            y = BALL_RADIUS + (BALL_RADIUS - y)
            dy = -dy
            bounced = True
        elif y + BALL_RADIUS > HEIGHT:
            y = HEIGHT - BALL_RADIUS - (y + BALL_RADIUS - HEIGHT)
            dy = -dy
            bounced = True

        # 반사 각도를 조금씩 흔들어, 같은 경로를 반복하며 모서리만
        # 오가는 상태에 갇히지 않게 한다
        if bounced:
            dx, dy = jitter_direction(dx, dy)

        # 속도가 매우 클 때를 대비한 안전 장치
        x = max(BALL_RADIUS, min(WIDTH - BALL_RADIUS, x))
        y = max(BALL_RADIUS, min(HEIGHT - BALL_RADIUS, y))

        # ------------------------------------------------------ 그리기
        screen.fill(BG_COLOR)
        pygame.draw.circle(screen, BALL_COLOR, (int(x), int(y)), BALL_RADIUS)

        speed_text = font.render(f"{speed_label}: {speed:6.1f} px/s", True, TEXT_COLOR)
        screen.blit(speed_text, (16, 14))

        hint = help_font.render("UP / DOWN : speed    SPACE : reset    ESC : quit",
                                True, (110, 110, 120))
        screen.blit(hint, (16, 48))

        pygame.display.flip()

    pygame.quit()
    sys.exit(0)


if __name__ == "__main__":
    main()

 

6. 실행 파일
이 코드는 pygame만 설치하면 동작합니다. 만약 실행하는 것만 보고 싶으신 분이 있다면 아래의 파일을 다운로드해서 실행해 볼 수 있습니다.

BouncingBall.zip
5.39MB
BouncingBall.z01
10.00MB
BouncingBall.z02
10.00MB