파이썬 예제 (양궁 게임)

개요

이번에 만든 파이썬 예제는 양궁게임(Archery Game) 입니다.

마우스를 클릭하면 움직이는 QProgressBar (수평, 수직) 를 이용해 x, y  좌표를 설정하고 과녁에 맞춘 위치 표시합니다.

심화반 초등학생이 C++, MFC로 만든 양궁게임에서 영감을 얻어 Python과 PyQt5를 이용해 만들해 보았습니다.

[양궁게임 실행화면]


설계 과정

1. 윈도우 창의 하단, 우측에 수평, 수직 QProgressBar를 배치 (X, Y 좌표 얻기 용도)

2. QFrame을 이용해 양궁 과녁판 제작 (정사각형)

[과녁판]

3. 양궁 과녁 점수판 사각형 영역 얻기 (과녁 사각형을 줄여가며)

[과녁 점수판 영역 설정]

4. 사각형에 내접하는 원으로 그리기

[과녁 점수판 원으로 표현]

5. 과녁 색상 입히기

[과녁 완성 모습]

6. 피타고라스의 정리를 이용해 점수 구하기

[화살 좌표로 점수 구하기]

좀 더 세부적인 내용은 소스코드를 보며 알아보겠습니다.

소스코드

2개의 파이썬 파일로 구성 (main.py, archery.py)

main.py

import sys
from PyQt5.QtWidgets import *
from threading import Thread, Lock
from archery import *
import time

QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True)

class CWidget(QWidget):

    updateHProgress = pyqtSignal(int)
    updateVProgress = pyqtSignal(int)

    def __init__(self):
        super().__init__()
        self.arch = archery(self)
        # 0:vertical shoot , 1:horizontal shoot, 2:get score 3:finish
        self.state = 0
        self.thread = Thread(target=self.threadFunc)
        self.lock = Lock()
        self.bFlag = True

        self.initUI()
        self.val = 0
        self.bIncrease = True
        self.signal = []
        self.signal.append(self.updateHProgress)
        self.signal.append(self.updateVProgress)
        self.signal[0].connect(self.pbar[0].setValue)
        self.signal[1].connect(self.pbar[1].setValue)
        self.thread.start()

    def initUI(self):

        self.setFixedSize(500,500)        
        self.frame = QFrame(self)
        self.frame.setFrameStyle(QFrame.Panel);
        self.frame.setGeometry(0,0,450,450) 
        
        self.pbar = []
        self.pbar.append(QProgressBar(self))        
        self.pbar[0].setRange(self.frame.rect().top(), self.frame.rect().bottom())
        self.pbar[0].setOrientation(Qt.Vertical) 
        self.pbar[0].setTextVisible(False)
        self.pbar[0].setGeometry(450,0,50,450)

        self.pbar.append(QProgressBar(self))        
        self.pbar[1].setRange(self.frame.rect().left(), self.frame.rect().right())
        self.pbar[1].setAlignment(Qt.AlignCenter)
        self.pbar[1].setTextVisible(False)
        self.pbar[1].setGeometry(0,450,450,50)        
        
        self.show()         

    def paintEvent(self, e):
        qp = QPainter()
        qp.begin(self)  
        qp.setRenderHint(QPainter.Antialiasing)
        self.arch.draw(qp)
        qp.end()

    def mousePressEvent(self, e):
        if e.button()==Qt.LeftButton:
            self.lock.acquire()
            if self.state>=3:
                self.state=0
            else:
                self.state+=1
                self.val = 0
            self.lock.release()

    def exitGame(self):        
        r = QMessageBox.information(self, 'Game Over', self.arch.getResult(), QMessageBox.Yes|QMessageBox.No)

        if r==QMessageBox.Yes:            
            del(self.arch)
            self.arch = archery(self)
            self.pbar[0].setValue(0)
            self.pbar[1].setValue(0)
        else:
            self.close()

    def closeEvent(self, e):
        self.bFlag = False   

    def threadFunc(self):
        while self.bFlag:
            
            self.lock.acquire()
                 
            if self.state == 0 or self.state == 1:           
                self.signal[self.state].emit(self.val)

                if self.bIncrease:
                    self.val+=1
                else:
                    self.val-=1

                if self.val > self.pbar[self.state].maximum():
                    self.bIncrease = False
                elif self.val < self.pbar[self.state].minimum():
                    self.bIncrease = True
            elif self.state == 2:
                self.arch.shoot(self.pbar[1].value(), self.pbar[0].maximum()-self.pbar[0].value())
                self.state+=1
            else:
                pass

            self.lock.release()
            time.sleep(0.001)

if __name__ == '__main__':
    app = QApplication(sys.argv)
    w = CWidget()
    sys.exit(app.exec_())

1~5번 라인은 PyQt5, Thread(실행흐름) 등 필요한 모듈을 import 하는 구문입니다.

9번 라인부터 QWidget에서 상속받은 CWidget class 선언이 시작됩니다.
    updateHProgress = pyqtSignal(int)
    updateVProgress = pyqtSignal(int)
위의 클래스 변수는 QProgressBar(수평, 수직)를 새로 그려주기 위한 사용자 정의 시그널입니다.

QProgressBar는 Main thread 안에서만 update(새로 그리기) 가능하므로, 차후 우리가 생성할 쓰레드에서 Main thread로 QProgressBar를 갱신하기 위해 보낼 Signal 입니다.

14~31번 init() 생성자 함수는 아래와 같습니다.
def __init__(self):
        super().__init__()
        self.arch = archery(self)
        # 0:vertical shoot , 1:horizontal shoot, 2:get score 3:finish
        self.state = 0
        self.thread = Thread(target=self.threadFunc)
        self.lock = Lock()
        self.bFlag = True

        self.initUI()
        self.val = 0
        self.bIncrease = True
        self.signal = []
        self.signal.append(self.updateHProgress)
        self.signal.append(self.updateVProgress)
        self.signal[0].connect(self.pbar[0].setValue)
        self.signal[1].connect(self.pbar[1].setValue)
        self.thread.start()

archery class의 객체(아래 archery.py 참조) 를 선언하고, 화살을 발사하는 방식이 세로(Y좌표), 가로(X좌표) 2번 클릭으로 한발이 발사되므로, 상태를 저장하기 위한 state변수도 선언합니다.

이어서 QProgressBar를 왔다갔다 반복 표시하기 위한 thread, 동기화를 위한 lock, 등의 변수를 선언하고 위에서 선언한 사용자 정의 시그널을 initUI() 함수에서 생성된 QProgressBar와 연결합니다.

마지막으로 thread를 start() 해 게임을 시작합니다.

33~53번 라인 initUI() 함수를 살펴보겠습니다.
def initUI(self):

        self.setFixedSize(500,500)        
        self.frame = QFrame(self)
        self.frame.setFrameStyle(QFrame.Panel);
        self.frame.setGeometry(0,0,450,450) 
        
        self.pbar = []
        self.pbar.append(QProgressBar(self))        
        self.pbar[0].setRange(self.frame.rect().top(), self.frame.rect().bottom())
        self.pbar[0].setOrientation(Qt.Vertical) 
        self.pbar[0].setTextVisible(False)
        self.pbar[0].setGeometry(450,0,50,450)

        self.pbar.append(QProgressBar(self))        
        self.pbar[1].setRange(self.frame.rect().left(), self.frame.rect().right())
        self.pbar[1].setAlignment(Qt.AlignCenter)
        self.pbar[1].setTextVisible(False)
        self.pbar[1].setGeometry(0,450,450,50)        
        
        self.show()         
생성자에 의해 호출되는 함수이며, 윈도우의 크기를 500x500으로 고정하고, 과녁판(QFrame)과 진행바(QProgressBar)를 생성합니다.

55~60번 라인 paintEvent() 함수는 QWidget이 가지고 있는 함수이며, 윈도우의 그림을 새로 그릴필요가 있을때 마다 호출됩니다.

C++, MFC의 Doc/View 구조의 OnDraw() 함수와 유사합니다.
def paintEvent(self, e):
        qp = QPainter()
        qp.begin(self)  
        qp.setRenderHint(QPainter.Antialiasing)
        self.arch.draw(qp)
        qp.end()
QPainter의 객체 qp를 선언하고 begin(), end() 사이에 그림을 그릴 코드를 넣어주면 됩니다.

코드가 간결해 보이는 이유는 QPainter의 객체 qp(그림을 그리는 팔 역할)를 arch객체의 draw(qp) 함수로 전달해 실제 화면에 표시되는 그림을 arch의 draw() 함수에서 그리기 때문입니다.

arch clas는 뒤에서 설명할 archery.py 파일에 나옵니다.

즉, 윈도우를 새로 그려야 할 필요가 있을 때 마다, 그림을 그리는 역할을 arch class로 위임한다고 생각하면 이해가 쉽습니다.

62~70번 라인 mousePressEvent() 함수는 마우스를 클릭할때 마다 호출되는 함수입니다.
def mousePressEvent(self, e):
        if e.button()==Qt.LeftButton:
            self.lock.acquire()
            if self.state>=3:
                self.state=0
            else:
                self.state+=1
                self.val = 0
            self.lock.release()

만약 마우스 왼쪽 버튼이 눌러지면, state 정수형 변수에 화살의 발사상태를 아래와 같이 저장합니다.

0 : Y축 발사
1 : X축 발사
2 : 발사완료(점수얻기)
3 : 화살 발사 과정 종료

72~81번 exitGame() 함수는 화살을 총 10발 다 쐈을 때 arch class에서 보내는 시그널에 의해 호출되는 슬롯함수입니다.
def exitGame(self):        
        r = QMessageBox.information(self, 'Game Over', self.arch.getResult(), QMessageBox.Yes|QMessageBox.No)

        if r==QMessageBox.Yes:            
            del(self.arch)
            self.arch = archery(self)
            self.pbar[0].setValue(0)
            self.pbar[1].setValue(0)
        else:
            self.close()

10발을 모두 쐈다면, 메시지 창을 열어 결과를 보여주고 한판 더 진행할지(Yes) , 게임을 종료(No) 할지 결정하도록 합니다.


83번 라인의 closeEvent() 함수는 윈도우 창을 닫을때 호출되는 함수이며, bFlag라는 bool 타입 변수를 거짓(False)으로 설정해 쓰레드의 while 무한 루프가 종료되도록 합니다.

86~110번 라인 threadFunc()는 생성자에서 만든 thread의 start()시 호출되는 target function 입니다.
def threadFunc(self):
        while self.bFlag:
            
            self.lock.acquire()
                 
            if self.state == 0 or self.state == 1:           
                self.signal[self.state].emit(self.val)

                if self.bIncrease:
                    self.val+=1
                else:
                    self.val-=1

                if self.val > self.pbar[self.state].maximum():
                    self.bIncrease = False
                elif self.val < self.pbar[self.state].minimum():
                    self.bIncrease = True
            elif self.state == 2:
                self.arch.shoot(self.pbar[1].value(), self.pbar[0].maximum()-self.pbar[0].value())
                self.state+=1
            else:
                pass

            self.lock.release()
            time.sleep(0.001)
while loop를 통해 무한 반복하며, 화살의 상태 저장 변수 state의 값이 0 or 1일 경우 QProgressBar를 최소값, 최대값 범위사이로 계속 이동하도록 합니다.

다만, 위에서 잠시 언급한대로, QProgressBar의 setValue() 함수를 통한 값 변경(QProgressBar가 repaint되어야 함)은 main thread 안에서만 가능하므로 클래스 변수로 만들어둔 사용자정의 signal을 송출(emit) 해 main thread에서 처리하도록 합니다.

화살을 X, Y 방향으로 모두 발사한 경우 state의 값은 2로 변경되어 이어서 설명할 arch class의 shoot() 함수를 호출하고 X, Y 좌표를 전달합니다.

thread안에서 사용된 Lock 객체를 이용한 동기화 처리는 main thread에서 수행하는 마우스 클릭 발생시 state의 값을 변경하는 행동을 하는데, 이를 해당 thread와 동기화 하기 위함입니다.

여기까지가 main.py 파일의 CWidget class에 대한 설명입니다.

main.py 파일의 역할을 요약하면 아래와 같습니다.

1. 윈도우(QWidget) 생성

2. 과녁창(QFrame), 진행바(QProgress) 생성

3. 마우스 클릭 시 진행바의 값을 읽어와 X, Y 좌표로 arch class로 전달


archery.py

이어서 두번째 소스코드파일인 archery.py 파일 분석입니다.
from PyQt5.QtGui import *
from PyQt5.QtCore import *
from threading import Timer
import math

class archery(QObject):

    updateWidget = pyqtSignal()
    gameOver = pyqtSignal() 

    def __init__(self, parent):
        super().__init__()
        self.parent = parent        
        self.trect = []
        self.nboard = 10
        self.bFirst = True

        self.maxcnt = 10
        self.cnt = 0
        self.pt = []
        self.score = []
        self.bScore = False        

        self.updateWidget.connect(self.parent.update)
        self.gameOver.connect(self.parent.exitGame)       

    def draw(self, qp):
        if self.bFirst:
            self.frect = self.parent.frame.rect()
            self.size = self.frect.width()/10+1
            self.bFirst = False

        #draw board, text
        brush = [Qt.white, Qt.black, Qt.blue, Qt.red, Qt.yellow]        
        score = 1
        for i in range(self.nboard):
            
            size = self.size/2
            rect = self.frect.adjusted(size*i, size*i, -size*i, -size*i)
            self.trect.append(QRectF(rect))

            qp.setBrush(brush[i//2])
            if i==3:
                pen = QPen(Qt.white, 1, Qt.SolidLine)
            else:
                pen = QPen(Qt.black, 1, Qt.SolidLine)
            qp.setPen(pen)            
            qp.drawEllipse(rect) 
            rect.adjust(0,0,-self.size/4, 0)
            if i==2:
                qp.setPen(Qt.white)
            qp.drawText(rect, Qt.AlignRight|Qt.AlignVCenter, str(score))
            score+=1

        #draw arrow
        size = 5
        cnt = 1
        qp.setPen(QColor(0,255,0))
        for pt in self.pt:
            qp.drawLine(pt.x()-size, pt.y()-size, pt.x()+size, pt.y()+size)            
            qp.drawLine(pt.x()+size, pt.y()-size, pt.x()-size, pt.y()+size)
            qp.drawText(pt.x()+size, pt.y(), str(cnt))
            cnt+=1

        #draw score
        if self.bScore and len(self.score) > 0:
            score = self.score[-1]
            f = QFont('Arial', 50)
            qp.setFont(f)            
            qp.drawText(self.pt[-1].x()+10,self.pt[-1].y() , str(score))

        #draw count
        qp.setPen(Qt.black)
        s = '{}/{}'.format(self.cnt, self.maxcnt)
        qp.drawText(self.frect, Qt.AlignTop|Qt.AlignLeft, s)

    def shoot(self, x, y):        
        self.cnt+=1
        self.getScore(x, y)
        self.pt.append(QPointF(x, y))
        self.bScore = True
        self.timer = Timer(2, self.showScore)
        self.timer.start()
        self.updateWidget.emit()
        
        if self.cnt==10:
            self.gameOver.emit()
            
    def showScore(self):
        self.bScore = False
        self.timer.cancel()
        self.updateWidget.emit()

    def getScore(self, x, y):
        cpt = self.frect.center()
        a = (cpt.x()-x)**2
        b = (cpt.y()-y)**2
        c = a+b
        c = math.sqrt(c)

        score = 0
        for i in range(self.nboard-1, -1, -1):
            radius = self.trect[i].width()/2
            if c<=radius:
                score = i+1
                break

        self.score.append(score)

    def getResult(self):
        result = ''
        
        for i, pt in enumerate(self.pt):
            result+='{}. score:{}, x:{}, y:{}\n'.format(i+1, self.score[i], int(pt.x()), int(pt.y()))   
            
        result += '\nTotal : {}'.format(sum(self.score))
        result += '\nrestart(Yes), exit(No)'
        return result

archery 라는 class하나로 구성되어 있습니다.

11번 라인의 init() 생성자 함수부터 살며보면 전달인자로 CWidget을 parent라는 이름으로 전달 받고 있습니다.
def __init__(self, parent):
        super().__init__()
        self.parent = parent        
        self.trect = []
        self.nboard = 10
        self.bFirst = True

        self.maxcnt = 10
        self.cnt = 0
        self.pt = []
        self.score = []
        self.bScore = False        

        self.updateWidget.connect(self.parent.update)
        self.gameOver.connect(self.parent.exitGame)    

archery class에서 실제 그림을 그리는 역할을 수행하므로 widget의 크기 정보 등을 알아야 하므로 반드시 필요합니다.

과녁의 점수(1~10점)판의 크기를 저장하기 위한 trect 리스트(QRectF type 저장), 화살 좌표를 저장하기 위한 pt 리스트(QPointF type), 점수를 저장하기 위한 score 리스트 (int type) 등을 선언합니다.

updateWidget, gameOver는 사용자 정의 signal 이며, parent로 저장해둔 widget으로 각각 새로그리기, 화살모두발사(10발) 시 보낼 메시지 입니다.

27~75번 라인의 draw()함수는 그림을 그리는 핵심입니다.
def draw(self, qp):
        if self.bFirst:
            self.frect = self.parent.frame.rect()
            self.size = self.frect.width()/10+1
            self.bFirst = False

        #draw board, text
        brush = [Qt.white, Qt.black, Qt.blue, Qt.red, Qt.yellow]        
        score = 1
        for i in range(self.nboard):
            
            size = self.size/2
            rect = self.frect.adjusted(size*i, size*i, -size*i, -size*i)
            self.trect.append(QRectF(rect))

            qp.setBrush(brush[i//2])
            if i==3:
                pen = QPen(Qt.white, 1, Qt.SolidLine)
            else:
                pen = QPen(Qt.black, 1, Qt.SolidLine)
            qp.setPen(pen)            
            qp.drawEllipse(rect) 
            rect.adjust(0,0,-self.size/4, 0)
            if i==2:
                qp.setPen(Qt.white)
            qp.drawText(rect, Qt.AlignRight|Qt.AlignVCenter, str(score))
            score+=1

        #draw arrow
        size = 5
        cnt = 1
        qp.setPen(QColor(0,255,0))
        for pt in self.pt:
            qp.drawLine(pt.x()-size, pt.y()-size, pt.x()+size, pt.y()+size)            
            qp.drawLine(pt.x()+size, pt.y()-size, pt.x()-size, pt.y()+size)
            qp.drawText(pt.x()+size, pt.y(), str(cnt))
            cnt+=1

        #draw score
        if self.bScore and len(self.score)>0:
            score = self.score[-1]
            f = QFont('Arial', 50)
            qp.setFont(f)            
            qp.drawText(self.pt[-1].x()+10,self.pt[-1].y() , str(score))

        #draw count
        qp.setPen(Qt.black)
        s = '{}/{}'.format(self.cnt, self.maxcnt)
        qp.drawText(self.frect, Qt.AlignTop|Qt.AlignLeft, s)

draw() 함수 최초 수행시 1회만 frect라는 QRectF타입의 변수에 과녁 영역의 사각형 정보를 저장합니다. 그리고 과녁의 점수마다 줄어드는 영역의 크기를 설정합니다.

위 코드는 bFirst라는 bool 타입 변수를 통해 1회만 수행되도록 설정합니다. widget의 크기가 고정되어 있으므로 불필요하게 매번 수행할 필요가 없습니다.

이어서 과녁판의 색을 지정할 리스트를 생성합니다.

가장 바깥쪽 점수판부터 흰색, 검은색, 파란색, 빨간색, 노란색의 순서로 점수판이 그려져야 합니다.

[점수판 색]

이제 for 반복문을 통해 10회 반복하며 아래 행동을 수행합니다.

양궁 과녁 그리기

1. 가장 바깥쪽 사각형 영역부터 전체크기를 10으로 나눈값으로 줄이기(adjust()  함수)


2. 작아진 사각형들을 trect 리스트에 저장

3. QBrush를 색상 순서대로 생성 (단 같은색 2개씩)



4. 3~4점 점수판은 검은색이므로 흰색 QPen으로 테두리 설정


5. Ellipse() 함수를 통해 점수판 사각형 영역에 내접하는 원 그리기

6. 점수판 사각형 영역의 오른쪽 끝에 점수 숫자(1,2...10) drawText()로 표시


양궁 화살 맞은곳 표시 그리기

1. X 표시로 그리기 위한 선길이 설정

2. 화살 좌표에서 X- , Y- 로 X표시 왼쪽 위 좌표 얻기

3. 화살 좌표에서 X+, Y+로 X표시 우 하단 좌표 얻기

4. 2와 3에서 구한 두 좌표로 drawLine(시작점, 끝점) 함수로 직선 그리기

5. 화살 X 표시 오른쪽에 현재 발사 번호(cnt) drawText() 로 글씨 적기


점수 및 발사 횟수 표시

1. 화살 발사시 2초간 크게 점수를 표시하기 위해 bScore bool type 변수 체크, 차후 타이머를 통해 bScore가 2초후 Fasle가 된다.

2. bScore 변수가 True 인 경우, QFont 생성 (font size 크게)

3. drawText 함수를 통해 점수 크게 표시 (2초후 타이머에 의해 False)

여기까지가 archery class의 draw() 함수 분석입니다.

77~87번 라인 shoot() 함수는 main.py에서 화살 발사과정 상태값 (state == 2)인 경우, 즉 한발이 온전하게 발사된 경우 호출되는 함수입니다.
 def shoot(self, x, y):        
        self.cnt+=1
        self.getScore(x, y)
        self.pt.append(QPointF(x, y))
        self.bScore = True
        self.timer = Timer(2, self.showScore)
        self.timer.start()
        self.updateWidget.emit()
        
        if self.cnt==10:
            self.gameOver.emit()

함수의 수행하는 동작은 아래와 같습니다.

1. 발사횟수 +1 증가

2. getScore() 함수 호출, 점수 얻기

3. 현재 화살 좌표 리스트 저장

4. 타이머 가동해 2초 후 bScore가 False가 되도록 하기

5. 새로 그리기 signal 보내기 -> QWidget paintEvent() 호출

6. 화살 모두 발사했다면 게임 종료 signal 보내기


89~92번 라인 showScore() 함수는 타이머 동작시 호출되는 함수이며, bScore bool type 변수를 거짓으로 만들어 점수표시가 큰 글씨로 2초동안만 그려지도록 합니다.

94~118번 라인 getScore() 함수는 화살의 X, Y 좌표를 전달 받아 점수를 구하는 함수입니다.
def getScore(self, x, y):
        cpt = self.frect.center()
        a = (cpt.x()-x)**2
        b = (cpt.y()-y)**2
        c = a+b
        c = math.sqrt(c)

        score = 0
        for i in range(self.nboard-1, -1, -1):
            radius = self.trect[i].width()/2
            if c<=radius:
                score = i+1
                break

        self.score.append(score)

1. 중심점 X 좌표 - 화살 X 좌표

2. 중심점 Y 좌표 - 화살 Y 좌표로 직각삼각형의 밑변(a)과 높이(b) 구하기.



3. 피타고라스의 정리에 따라 밑변제곱 + 높이제곱 = 빗변제곱과 같으므로 a, b를 제곱해 더한후 루트를 씌워 빗변(c)의 길이 얻기.

4. 점수판 안쪽(10점) 사각형 부터 너비를 2로 나누어 c의 길이가 짧거나 같은지 비교해 점수판 원 안에 있는지 판단.

5. 점수 리스트에 저장

110~118번 라인 getResult() 함수는 화살을 10발 모두 쏘았을때 호출되는 함수입니다.
    def getResult(self):
        result = ''
        
        for i, pt in enumerate(self.pt):
            result+='{}. score:{}, x:{}, y:{}\n'.format(i+1, self.score[i], int(pt.x()), int(pt.y()))   
            
        result += '\nTotal : {}'.format(sum(self.score))
        result += '\nrestart(Yes), exit(No)'
        return result

python enumerate() 함수로 화살의 좌표가 저장된 pt 리스트의 인덱스와 값을 얻어 반복하며 발사 순서와 점수를 문자열로 만들어 리턴합니다.



이상으로 전체 소스코드 분석을 마칩니다.

개발환경

  • Windows 10 pro, MS Visual Studio 2017
  • Python 3.7.2, PyQt5 5.13

실행파일 링크


감사합니다.

댓글

이 블로그의 인기 게시물

Qt Designer 설치하기

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