파이썬 예제 (포물선 운동)

대학 시절 "포트리스"라는 게임이 있었습니다.

포탄이 날아가는 속도와 각도를 조절해 상대를 맞추는 게임인데, 비슷하게 파이썬으로 구현해 보았습니다.

실제 게임의 요소(적군, 충돌, 바람)는 코드 간단화를 위해 구현하지 않았습니다, 이 글을 참조해 여러분이 직접 한번 코드로 작성해 보면 좋겠습니다.

개요

  • Python, PyQt5 를 이용한 간단 게임
  • 탱크에서 발사되는 포탄에 포물선 운동을 적용
  • Widget, Game, Tank, Bullet 4개의 class를 이용한 객체지향적 설계 

[실행화면]


설계 방향

  • 키보드 좌우 (탱크 이동), 상하(포탑 각도) 처리
  •  스페이스 키 눌러짐(탄환 속도 증가), 키 업(탄환 발사)
  • 4개의 클래스로 구성 (CWidget:배경, CGame:게임 관리, CTank:탱크, CBullet:포탄)
  • Draw, Keyboard Event 처리는 CWidget이 CGame에 위임
  • CTank는 직사각형으로 그려지고, 대포, 포탄들, 각도, 발사속도를 가짐
  • CBullet은 하나의 포탄을 의미하는 클래스, 위치, 속도, 각도, 착탄 여부를 가짐
  • CTank 는 CBullet type 객체를 리스트로 저장 (발사 포탄은 다수이기 때문)
  • 발사된 포탄(CBullet)은 스스로 자신의 위치를 갱신 (포물선 운동)
  • 발사된 포탄의 좌표가 윈도우 바닥 Y 좌표보다 크면 착탄이므로 삭제

소스 코드

  • 총 3개의 파일로 구성 (main.py, game.py, tank.py)
  • main.py (main 함수, CWidget class)
  • game.py (CGame class) 
  • tank.py (CTank, CBullet class) 

main.py 코드

앱과, 위젯을 생성하는 역할을 담당.
from game import *
from PyQt5.QtWidgets import *
import sys
 
QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True)
 
class CWidget(QWidget):
 
    barUpdate = pyqtSignal(int)
 
    def __init__(self):
        super().__init__()
        self.S = False
        self.setFixedSize(800,500)
        self.game = CGame(self)
        self.initUI()
 
        self.barUpdate.connect(self.bar.setValue)
 
    def initUI(self):
        self.bar = QProgressBar(self)        
        self.bar.setRange(0,100)
        self.bar.setValue(0)
        self.bar.setAlignment(Qt.AlignCenter)  
        self.bar.hide()
        self.show() 
 
    def paintEvent(self, e):        
        qp = QPainter()
        qp.begin(self)
        self.game.draw(qp)
        qp.end()
 
    def keyPressEvent(self, e):
        if not e.isAutoRepeat():            
            self.game.keyDown(e.key())
            if e.key() == Qt.Key_Space:
                pt = self.game.tank.r.topLeft()
                h = self.game.tank.r.height()
                self.bar.move(pt.x(), pt.y()-h*2)
                self.bar.show()
                self.S = True
                self.thread = Thread(target=self.threadFunc)
                self.thread.start()                
 
    def keyReleaseEvent(self, e):
        if not e.isAutoRepeat():            
            self.game.keyUp(e.key())            
            if e.key() == Qt.Key_Space:
                self.bar.hide()
                self.S = False
 
    def closeEvent(self, e):
        self.S = False
        self.game.bRun = False
 
    def threadFunc(self):
        while self.S:
            sp = 1
            val = self.bar.value()
            self.barUpdate.emit(val+sp)            
 
            time.sleep(0.01)
             
        self.barUpdate.emit(0)
 
 
if __name__ == '__main__':
    app =  QApplication(sys.argv)
    w = CWidget()
    sys.exit(app.exec_())

1~5번 라인은 필요한 모듈을 불러오고, 4k 모니터를 위한 고해상도 설정 코드입니다.
QApplication class의 Static(정적) 함수인 setAttribute() 로 설정합니다.

7번 라인부터 CWidget class 이며, QWidget에서 상속받는 구조입니다.

11번 라인 생성자 함수에서 상속관계를 갖는 클래스이므로 super() 를 통해 부모 생성자를 호출하고, 위젯의 사이즈를 고정합니다.

이후 실제 게임을 진행하는 역할인 CGame 클래스의 객체를 생성하고 객체변수로 저장해 둡니다.

20번 라인 initUI() 함수는 CWidget 생성자에서 호출되는 함수이며, 포탄의 발사 속도를 시각적으로 표현할 QProgressBar를 생성하고 속도 범위를 0~100으로 설정 후 일단 감춰(Hide) 둡니다.

차후 키보드 스페이스바를 누르면 보여지고, 쓰레드를 생성해 키보드가 눌러진 동안 속도를 증가시킬 계획입니다.

[스페이스키가 눌러진 동안만 증가]

설명하지 않는 내용이 있는데 9번 라인의 barUpdate는 사용자 정의 시그널입니다. Qt의 QProgressBar는 메인쓰레드에서만 update(값 변경, 갱신) 가능하므로, 나중에 사용자 쓰레드에서 이 클래스 변수를 이용해 갱신 신호를 보내(emit) 프로그레스바의 값을 변경해야 합니다.

잘 이해되지 않는다면 Qt의 Signal Slot 메카니즘에 대해 공부해 보기 바랍니다.

28번 라인의 paintEvent()는 Widget이 생성되고, 새로 그려져야 할 필요가 있을때 마다 호출되는 함수입니다.

QWidget이 가지고 있는 함수 Overriding 이며, 오버라이딩은 상속관계를 갖는 클래스 구조에서 자식 클래스가 부모의 함수를 재정의 하는 것을 의미합니다.

이어서 QPainter 객체 qp(그림을 그리는 일종의 팔 역할)를 생성하고 begin(), end() 사이에 그려야 할 내용을 작성하면 됩니다. 우리는 위에서 생성한 CGame 객체의 draw() 함수에 qp를 전달인자로 넘겨, 대신 그림을 그리게 합니다.

즉, 위젯이 새로 그려질 필요가 있는 경우, CGame 객체의 draw() 함수가 호출되며 여기서 실제 게임에 대한 그리기를 처리합니다.

34번 keyPressEvent() 또한 함수 overridding이며, 키보드가 눌러지면 호출됩니다.

여기서 isAutoRepeat()은 키보드가 눌러져 있더라도 한번만 키 입력을 처리하기 위함이며, paintEvent와 마찬가지로 CGame 클래스로 키보드 처리를 위임합니다.

추가적으로, 스페이스키가 눌러졌다면, 탱크의 위쪽 영역으로 QProgressBar를 이동시키고 보여지도록 합니다. 그리고 별도의 Thread를 생성해 동작시킵니다.

57번 라인의 threadFunc()은 바로 여기서 호출되는 대상 함수이며, self.S 즉 스페이스 키의 눌러짐을 감지하는 bool 타입 변수가 참인 동안 프로그레스바(속도)를  증가시키고, 키 Up 이 감지 되면 False로 변해 while 문을 탈출하고 종료됩니다.

이 쓰레드에서 앞서 설명한, barUpdate 시그널이 송출(emit) 되는 코드를 볼 수 있습니다.

46번 라인 keyReleaseEvent()는 키보드 Up 이 감지되면 호출되는 함수이며, 속도 증가 쓰레드를 종료하고 QProgressBar를 감추도록 합니다.

68번 라인은 이 프로그램의 메인 함수(시작점) 역할이며, QApplication 객체와 위에서 설명한 CWidget 클래스의 객체를 생성해 프로그램을 실행하는 역할을 담당합니다.

game.py 코드

그림을 그리고, 키보드 이벤트 처리, 탱크 객체 이동, 등 전반적인 게임 운영을 담당.
from tank import *
from PyQt5.QtGui import *
 
class CGame(QObject):
 
    update = pyqtSignal()    
 
    def __init__(self, w):
        super().__init__()
        self.parent = w
        self.rect = self.parent.rect()
 
        # key board (True : pressed, False : released)
        self.L = False
        self.R = False
        self.U = False
        self.D = False       
 
        self.initGame()
 
        # user signal
        self.update.connect(self.parent.update)        
 
        # thread
        self.thread = Thread(target=self.threadFunc)
        self.bRun = True
        self.thread.start()
 
    def initGame(self):
        # tank body
        size = QSizeF(100, 30)
        x = self.rect.width()/20
        y = self.rect.bottom()-size.height()
        self.tank = CTank(x, y, size, self.rect)        
 
    def draw(self, qp):   
        # draw tank and cannon
        brush = QBrush(QColor(116,96,14), Qt.DiagCrossPattern)
        qp.setBrush(brush)
        qp.drawRect(self.tank.r)
        gun_start = self.tank.r.center()
        gun_end = self.tank.getRotatedPos(self.tank.deg, self.tank.len, gun_start)
        pen = QPen(QColor(175,145,22), 5, Qt.SolidLine)
        qp.setPen(pen)
        qp.drawLine(gun_start, gun_end)
 
        # draw bullet
        size = 5       
        qp.setPen(Qt.black)
        qp.setBrush(Qt.black)
        k = 0
        for b in self.tank.bullet[:]:
            if b.impact:
                del(self.tank.bullet[k])
            else:
                x = b.pt.x()-size
                y = b.pt.y()-size
                qp.drawEllipse(x, y, size*2, size*2)
                k+=1
 
        # draw info        
        pen = QPen(QColor(0,0,0), 2, Qt.SolidLine)
        qp.setPen(pen)
        text = 'Speed: {}\nDegree: {:1.1f}'.format(self.tank.sp, self.tank.deg)
        font = QFont('arial', 15)
        qp.setFont(font)
        qp.drawText(self.rect, Qt.AlignLeft | Qt.AlignTop, text)
 
    def keyDown(self, key):        
        if key == Qt.Key_Left:
            self.L = True
        elif key == Qt.Key_Right:
            self.R = True
         
        if key == Qt.Key_Up:
            self.U = True
        elif key == Qt.Key_Down:
            self.D = True    
 
    def keyUp(self, key):                
        if key == Qt.Key_Left:
            self.L = False
        elif key == Qt.Key_Right:
            self.R = False
         
        if key == Qt.Key_Up:
            self.U = False
        elif key == Qt.Key_Down:
            self.D = False
 
        if key == Qt.Key_Space:
            self.tank.shoot(self.parent.bar.value())            
 
    def threadFunc(self):
        while self.bRun:
            # move tank
            sp = 1
            if self.L:
                self.tank.r.adjust(-sp, 0, -sp, 0)
            elif self.R:
                self.tank.r.adjust(sp, 0, sp, 0)
 
            # up, down gun
            deg = 0.1
            if self.U and self.tank.deg > -90+deg:                
                self.tank.deg -= deg                
            elif self.D and self.tank.deg < 90-deg:
                self.tank.deg += deg
 
            # redraw
            bDraw = self.L or self.R or self.U or self.D
            for bullet in self.tank.bullet:
                if bullet.impact == False:
                    bDraw |= True
            if bDraw:
                self.update.emit()
 
            time.sleep(0.01)
4번 라인부터 CGame 클래스를 선언합니다.

CGame 클래스는 QObject에서 상속받도록 되어있으며, 그 이유는 위에서 언급한 사용자 정의 시그널, 즉 pyqtSignal()을 선언하고 사용하기 위해 필요합니다.

update라는 이름의 클래스 변수는 앞서 설명한 위젯(CWidget)의 paintEvent() 함수를 호출하기 위해 보내는 사용자정의 시그널 입니다.

CGame은 탱크 이동, 포탑의 각도변경, 포탄의 이동에 따른 좌표가 변경될 때 마다 실제 그림을 그리는 CWidget의 paintEvent() 함수를 호출해 화면을 새로 그려야 하며, 이때 self.update라는 시그널(신호)을 송출해 이 시그널과 연결된 CWidget의 update함수(슬롯)를호출하는 방식입니다.

Qt는 객체간 통신을 위해 Siganl , Slot 메커니즘을 사용하며, 시그널은 어떤일이 생겼을때를 의미하며 슬롯은 그때 호출되는 함수라고 생각하면 됩니다.

아래는 Qt 공식 사이트에 설명된 내용입니다.

Signals & Slots

Signals and slots are used for communication between objects. The signals and slots mechanism is a central feature of Qt and probably the part that differs most from the features provided by other frameworks. Signals and slots are made possible by Qt's meta-object system.

8번 __init__()함수는 CGame의 생성자, 즉 객체가 생성되면 호출되는 함수이며 전달인자로 위젯 객체를 전달받아 parent라는 이름으로 저장해 둡니다.

self.R, L, U, D 라는 bool type 변수는 키보드 상,하,좌,우 화살표 버튼이 눌러지면 True, 떨어지면 False값을 갖게 되며, 다음의 용도로 사용됩니다.
  • L, R : 탱크의 좌, 우 이동
  • U, D : 포탑 각도의 이동
keyPressEvent() 시 바로 처리하지 않고, 별도의 변수로 만들어 관리하는 이유는 키보드 입력 이벤트는 그 간격(누르고 있는 동안)이 매우 느리게 발생되기 때문입니다.

만약 키보드가 눌러졌을 때 탱크 이동 코드를 만들면 탱크의 움직임이 끊어지듯 이동해 부드럽게 구현되지 않습니다.

따라서, 키눌러짐 여부만 감지하고 별도의 쓰레드에서 빠르게 처리하기 위함입니다.

이어서 initGame()  함수를 호출해 CTank의 객체를 생성하고 탱크를 화면 아래쪽에 위치시킵니다. (CTank 클래스는 tank.py 코드에서 설명)

생성자의 마지막으로, Thread를 생성하고 실행합니다. 쓰레드에 의해 호출되는 94번 라인 threadFunc() 함수는 무한 루프로 반복하며 위에서 설명한 self.L, R 변수를 이용해 탱크의 크기를 저장하는 정보(QRectF type)를 adjust() 함수를 통해 변경합니다.

QRectF 클래스의 메서드인 adjust()함수는 4개의 전달인자를 가지며 순서대로, 사각형 왼쪽 위좌표의 x, y, 사각형 오른쪽 아래 좌표의 x, y 입니다. 즉 topLeft, bottomRight 를 의미합니다.

이 값을 설정하면 마치 offset처럼 작용해 사각형의 현재 좌표에서 이 값을 더하거나, 빼주는 역할을 수행합니다.


self.L이 참이면 두 좌표의 X를 감소, self.R 이 참이면 X만 증가시키면 탱크좌표는 좌,우로 이동하게 됩니다.

그 다음 self.U, self.D 은 포탑의 각도를 증가, 감소시키는 역할입니다.

110번 redraw는 탱크가 이동 중, 포탑의 각도가 변경 중, 포탄이 날아가는 중이라면 화면을 갱신 처리하는 부분으로, 불필요한 화면 갱신을 막아 CPU의 사용량을 낮춰 주도록 합니다.

36번 라인 draw() 함수는 CWdiget의 paintEvent()에서 호출되는 함수이며, 전달인자로 위젯의 QPainter 객체를 받아와 그림을 그리는 역할을 수행합니다.

41번 라인 포탑 그리는 코드는 다음의 순서대로 포탑을 그려냅니다.

1. 탱크 사각형의 중심점 좌표 얻기

2. 중심점, 포탑 각도, 포탑 길이를 활용, 삼각함수로 포탑의 끝점 좌표 얻기

3. 중심점, 끝점 좌표로 선 그리기


47번 라인 포탄 그리는 코드는 아래와 같습니다.

1. 탱크 객체가 갖는 포탄 리스트 복사본 수 만큼 반복

2. 포탄이 땅에 떨어졌다면 (b.impact == true) 포탄 삭제

3. 아니면 포탄 그리기

다음으로 69~92번 라인까지 이어지는 keyDown, keyUp 함수입니다.

특별히 설명할 것도 없이, 키눌러짐을 감지해 bool type 변수에 True, False 만 저장하는 용도입니다.

다만 91번 라인, 키보드 스페이스가 해지되면 CTank 객체의 shoot() 함수를 호출하며 전달인자로 QProgressBar의 속도값 (0~100)을 전달합니다.

tank.py 코드

CTank, CBullet 클래스 정의, 포탄의 발사(스페이스키 떨어짐) 마다 포탄 객체를 생성하고 포탄 내부 쓰레드에서 포물선 운동 적용.
from PyQt5.QtCore import *
from threading import Thread
import time
import math
 
class CBullet:
    def __init__(self, pt, v, deg, maprect):
        # 총알 위치, 속도, 각도, 중력가속도
        self.pt = pt
        self.v = v
        self.deg = deg-90
        self.g = 9.80665
        self.map = maprect
        # 착탄여부
        self.impact = False       
        self.thread = Thread(target = self.threadFunc)
        self.thread.start()       
 
    def threadFunc(self):
        t = 0
        rad = self.deg * math.pi / 180
        pt = QPointF(self.pt)
        while True:
            # 포물선 방정식
            vx = self.v * math.cos(rad)
            vy = self.v * math.sin(rad) + self.g * t
            self.pt.setX(pt.x() + vx*t)
            self.pt.setY(pt.y() + (vy*t - 0.5*self.g*t*t))
            t += 0.1
 
            if self.map.bottom() < self.pt.y():
                break
             
            time.sleep(0.01)
        self.impact = True
 
class CTank:
    def __init__(self, x, y, size, maprect):
        self.maprect = maprect
        # tank rect
        self.r = QRectF(QPointF(x, y), size)        
        # cannon length
        self.len = self.r.width()/2
        # cannon degree, speed
        self.deg=45
        self.sp=0
        # bullet
        self.bullet = []        
 
    def getRotatedPos(self, deg, radius, cpt):        
        rad = deg * math.pi / 180
        dx = math.sin(rad)*radius
        dy = math.cos(rad)*radius       
        return QPointF(cpt.x()+dx, cpt.y()-dy)
 
    def shoot(self, speed):
        self.sp = speed
        pt = self.getRotatedPos(self.deg, self.len, self.r.center())
        bullet = CBullet(pt, speed, self.deg, self.maprect)
        self.bullet.append(bullet)
6번 라인 CBullet 클래스는 날아가는 탄환이며 생성자의 전달인자로 위치, 속도, 각도, 위젯의 사각형 크기를 전달 받고 중력가속도 등의 객체 변수를 갖습니다.

생성시 전달받은 변수를 이용, 즉시 쓰레드를 생성해 19번 라인 쓰레드에서 포탄의 다음 위치를 갱신합니다.

포물선 운동에 대한 수학적 내용은 위키백과를 참조하기 바라며 코드 위주로 설명해 보겠습니다.

포탄의 발사 시작 위치는 포탑의 끝 좌표 입니다. 이제 포물체의 속도 x, y 성분을 구해야죠.

먼저 포물선 운동에서 수평 방향 가속도는 없으므로, 속도 벡터의 x, y 성분은 아래와 같이 구합니다.
  • vx = 속도 * cos(theta)
  • vy = 속도 * sin(theta) + 중력가속도 * 시간 
여기서 중력가속도 * 시간을 빼지 않고 더해준 이유는 Qt의 기본 화면 좌표계는 y가 아래로 가면 증가되기 때문입니다.

참고로, 예전에 벡터 예제에서 벡터의 크기(magnitude)는 피타고라스 정리를 이용해 구할 수 있다고 설명드린바 있습니다. (vx제곱 + vy제곱 에 루트 씌움)

속도는 위치에 영향을 미치므로 시간에 따른 위치(변위)를 다음과 같이 구합니다.
  • x' = x + vx * 시간
  • y' = y + (vy * 시간 - 0.5 * 중력가속도 * 시간 제곱)  
시간(t)을 증가시키며 위 행위를 포탄의 y 좌표가 위젯의 바닥좌표 y 보다 커질 때 까지(바닥에 포탄 착탄)반복합니다.

착탄되면 포탄을 저장한 리스트에서 삭제하기 위해 impact 변수를 참으로 변경하고 쓰레드를 종료합니다.

다음에 포탄을 그릴 때 착탄된 포탄은 리스트에서 삭제 됩니다.

여기서 하나 짚고 넘어갈 부분은, 포탄의 좌표 계산화면에 그려지는 문제는 별개로 생각해야 합니다.

포탄 클래스는 생성되면 포물선의 운동 좌표 계산을 위와 같이 수행하나, 이는 눈에 보이지 않습니다. 실제 그림이 그려지는 이유는 CGame 클래스가 draw()함수를 통해 모든 포탄들의 리스트를 순회하며, 그 좌표들을 QPainter 객체를 이용해 그려주기 때문입니다.

마지막으로 37번 라인 CTank class 를 살펴보겠습니다.

생성자 함수에서 탱크의 사각형 크기(x, y, size)를 전달인자로 넘겨받습니다.

x, y는 사각형의 왼쪽 윗점의 좌표이며, size는 QSize 타입의 변수로 너비와 높이를 의미합니다. 이를 이용해 QRecfF type으로 탱크의 사각형 좌표를 저장합니다.

QRectF는 QRect와 비슷하지만 사각형의 정보를 실수(float type)으로 저장하므로 좀 더 세부적인 좌표 설정이 가능합니다.

이어서 포탑의 길이를 사각형 너비/2로 설정하고, 각도와 발사속도 변수를 만들어 포탄 발사시 적용하도록 합니다.

48번 라인에서 여러발의 포탄을 저장하는 self.bullet = [] 을 빈 리스트로 초기화 해 둡니다. 바로 이 리스트에 위에서 설명한 CBullet type 객체들이 발사 순서대로 저장됩니다.

50번 라인 getRotatedPos() 함수는 중심점과 각도, 반지름을 전달인자로 받아 삼각함수를 이용해 각도만큼 변경된 선의 끝 좌표를 찾아 줍니다.

이 함수는 포탑의 중심점과 길이, 각도를 이용해 포탑의 끝점 좌표(즉, 탄환의 발사지점)를 구해주는 역할을 수행합니다.

56번 라인 shoot() 함수는 CGame 클래스에서 키보드 스페이스키가 떨어질때 호출되는 함수이며, 바로 포탄의 발사함수입니다.

전달인자로 넘어온 속도(speed), getRotatedPos() 함수를 이용한 발사 위치, 각도를 이용해 CBullet 클래스의 객체를 생성하고 self.bullet 리스트에 추가합니다.

바로 이 순간 CBullet 클래스의 생성자( __init__() ) 함수가 호출되며, 탄환은 발사되게 됩니다.

이상으로 코드 분석을 마칩니다.

전체 코드 진행 흐름

1. main.py 에서 CWidget Class 객체 생성

2. CWidget 생성자에서 CGame Class 객체 생성

3. CGame 생성자에서 CTank 생성

4. 키보드 이벤트 발생 시 (상,하,좌,우 화살표) 탱크 이동 및 포탑 각도 변경

5. 키보드 이벤트 발생 시 (스페이스 키) 탄환 속도(Press) 증가 및 발사(Release)

6. 탄환 발사 시 CBullet class 객체 생성, 리스트 추가

7. CBullet 생성자에서 쓰레드 가동 후 포물선 운동 계산, 착탄 시 쓰레드 종료

8. CGame 쓰레드에서 탱크, 탄환의 좌표를 화면에 그리기

개발환경

  • Windows 10 Pro, Visual studio 2017 Pro
  • Python 3.7(64bit), PyQt 5.13.1

실행파일

댓글

이 블로그의 인기 게시물

Qt Designer 설치하기

C++ 예제 (소켓 서버, 이미지, 파일전송)