PyQt5를 이용한 GUI 오목앱 만들기

개요

오목게임을 파이썬을 이용해 만들어본 예제입니다.

GUI 앱을 처음 시도해보는 초보가가 공부하기 좋은 예제라고 생각합니다.


 

개발환경

  • Windows 11 Pro, Visual Studio 2022
  • Python 3.9 64bit, PyQt5 5.15.4

 

소스코드

두개의 파이썬 파일 (*.py) 로 구성되어 있으며 (main.py, game.py), 

main.py 를 시작파일로 설정하고 진행.

main.py

from PyQt5.QtWidgets import QApplication, QWidget, QMessageBox
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QPainter
from game import Omok
import sys

QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True)

class Window(QWidget):

    def __init__(self):
        super().__init__()
        self.setWindowTitle('Ocean Coding School')
        self.setFixedSize(600,600)

        self.omok = Omok(self)

    def paintEvent(self, e):
        qp = QPainter()
        qp.begin(self)
        self.omok.draw(qp)
        qp.end()

    def mousePressEvent(self, e):
        self.omok.mouseDown( e.x(), e.y() )

    def gameOver(self, w):
        if w==1:
            winner = 'Black win'
        elif w==2:
            winner = 'White win'
        else:
            winner = 'Draw'

        yesno = QMessageBox.information(self, winner, 'Retry(Yes) Exit(No)',
                                       QMessageBox.Yes | QMessageBox.No)

        if yesno==QMessageBox.Yes:
            del(self.omok)
            self.omok = Omok(self)
        else:
            self.close()


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

[라인 1~7] 

PyQt5 모듈에서 필요한 클래스를 import 하는 구문.

7번 라인은 모니터 픽셀의 밀도를 자동으로 증가시키는 코드로, 4K모니터 사용시 유용.

자세한 내용은 아래의 Qt Documenation 참조.


[라인 11~16]

QWidget에서 상속받은 Window 클래스의 생성자 함수.

뒤에서 설명할 game.py 파일에 선언된 Omok Class 의 객체를 생성.

실제 오목게임이 그려지고 진행되는 것은 Omok 클래스가 담당, Window 클래스는 창만 생성하고 게임의 역할은 game.py로 코드를 분산하는 구조를 가지도록 설계.


[라인 18~22]

Qt의 QWidget Class에 선언된 함수의 재정의 (Override).

위젯을 새로 그려야 할 필요가 있을 때 마다 호출되는 함수.

그림을 그릴 QPainter 객체를 생성하고, Omok Class의 draw() 전달인자로 넘겨 실제 그림을 그리는 곳은 Omok 클래스가 담당하도록 유도.


[라인 24~25]

마찬가지로 Qt의 QWidget Class에 선언된 함수의 재정의 (Override).

마우스를 클릭한 좌표를 Omok Class로 알려주고 처리하도록 유도. 


[라인 27~42]

오목게임의 종료 시 (흑승, 백승, 무승부),

game.py에 선언된 Omok Class에서 보내는 종료신호, End Signal 발생.

이 Signal과 연결된 Slot Function 으로 게임의 승자를 메시지박스로 출력.

참고로 Qt에서는 Signal이 발생될 때 호출되는 Callback Function을 슬롯함수라고 부름.


[라인 45~49]

Python 코드의 메인함수, 시작점.


game.py

from PyQt5.QtCore import QRect, QObject, pyqtSignal
from PyQt5.QtGui import QColor, QBrush
from PyQt5.QtWidgets import QMessageBox

class Omok(QObject):

    end_signal = pyqtSignal(int)

    def __init__(self, w):
        super().__init__()
        self.parent = w
        self.end_signal.connect(self.parent.gameOver)

        self.rect = w.rect()

        self.outrect = QRect(self.rect)
        gap = 20
        self.outrect.adjust(gap, gap, -gap, -gap)

        self.inrect = QRect(self.outrect)
        gap = 20
        self.inrect.adjust(gap, gap, -gap, -gap)

        self.line = 19
        self.size = self.inrect.height() / (self.line-1)
        
        self.bdol = []
        self.wdol = []
        self.bturn = True

        # 0:None, 1:Black, 2:White
        self.map = [[0 for _ in range(self.line)] for _ in range(self.line)]
        #print(self.map)


    def draw(self, qp):
        # board
        b = QBrush( QColor(156,114,15))
        qp.setBrush(b)
        qp.drawRect(self.outrect)

        # line
        x  = self.inrect.left()
        y  = self.inrect.top()
        x1 = self.inrect.right()
        y1 = self.inrect.top()
        x2 = self.inrect.left()
        y2 = self.inrect.bottom()

        for i in range(self.line):
            qp.drawLine(x, y+self.size*i, x1, y1+self.size*i)
            qp.drawLine(x+self.size*i, y, x2+self.size*i, y2)

        # stone
        b = QBrush(QColor(0,0,0))
        qp.setBrush(b)
        for rect in self.bdol:
            qp.drawEllipse(rect)

        b = QBrush(QColor(255,255,255))
        qp.setBrush(b)
        for rect in self.wdol:
            qp.drawEllipse(rect)

    def mouseDown(self, x, y):
        if self.inrect.contains(x, y):
            r, c = self.findRowCol(x, y)
            #print(r,c)
            if self.map[r][c]==0:
                L = (self.inrect.left()-self.size//2) + c*self.size
                T = (self.inrect.top()- self.size//2) + r*self.size
                rect = QRect(L, T, self.size, self.size)

                if self.bturn:
                    self.map[r][c] = 1
                    self.bdol.append(rect)
                else:
                    self.map[r][c] = 2
                    self.wdol.append(rect)

                self.bturn = not self.bturn
                self.parent.update()

                w = self.findWinner()
                if w>0:
                    self.end_signal.emit(w)

            else:
                QMessageBox.warning(self.parent, 'Error', 'Already laid', QMessageBox.Ok)
        else:
            QMessageBox.warning(self.parent, 'Error', 'Click inside a board', QMessageBox.Ok)

    def findRowCol(self, x, y):        
        r = (y-self.inrect.top()-self.size//2)  // self.size
        c = (x-self.inrect.left()-self.size//2) // self.size
        return int(r+1), int(c+1)


    # 0:continue, 1:Black win, 2:White win, 3:Draw
    def findWinner(self):
        cnt = 5
        empty = 0

        for r in range(self.line):
            for c in range(self.line):

                if self.map[r][c]==0:
                    empty+=1

                b = [0,0,0,0]
                w = [0,0,0,0]
                for i in range(cnt):                    
                    if c+i<self.line and self.map[r][c+i]==1:
                        b[0]+=1
                    if r+i<self.line and self.map[r+i][c]==1:
                        b[1]+=1
                    if (c+i<self.line and r+i<self.line) and self.map[r+i][c+i]==1:
                        b[2]+=1
                    if (c-i>=0 and r+i<self.line) and self.map[r+i][c-1]==1:
                        b[3]+=1

                for i in range(cnt):                    
                    if c+i<self.line and self.map[r][c+i]==2:
                        w[0]+=1
                    if r+i<self.line and self.map[r+i][c]==2:
                        w[1]+=1
                    if (c+i<self.line and r+i<self.line) and self.map[r+i][c+i]==2:
                        w[2]+=1
                    if (c-i>=0 and r+i<self.line) and self.map[r+i][c-1]==2:
                        w[3]+=1

                for i in b:
                    if i>=cnt: return 1;
                for i in w:
                    if i>=cnt: return 2;

        if empty==0:
            return 3 #draw
        return 0

[라인 9~33]

Omok Class의 생성자함수.

전달인자로 받은 위젯정보를 저장하고, 오목 게임에 필요한 변수 생성 등 초기화를 수행.

2차원 리스트로 생성된 self.map 은 향후 오목게임이 진행될 때 바둑판에 놓여진 돌의 상태를 저장. (빈곳:0, 흑돌:1, 백돌:2)

아래 그림은 상태를 저장할 2차원 리스트를 출력해본 모습이며 아직 바둑판에 놓여진 돌은 없다.


[라인 36~63]

앞서 설명한 main.py의 paintEvent() 에 의해 호출되는 함수.

전달인자로 받은 QPainter 객체를 이용해 바둑판의 선, 바둑돌을 그리는 역할을 담당.


[라인 65~91]

앞서 설명한 main.py의 mousePressEvent() 에 의해 호출되는 함수.

바둑판을 클릭한 좌표를 이용, 클릭한 위치와 가장 가까운 바둑판 교차점행,열 값 을 찾아낸다.

만약 그곳에 돌이 놓여져 있지 않다면, 바둑돌이 그려질 사각형 좌표를 생성해 흑돌, 백돌 리스트에 저장.

흑돌, 백돌의 순서를 바꾸고 판정함수를 호출해 승패를 검사.


[라인 93~96]

위젯을 마우스로 클릭한 좌표를 기준으로 가장 가까운 교차점의 행,열을 반환하는 함수.


[라인 100~139]

바둑돌을 놓을때마다 호출되는 판정함수. 리턴값은 아래 넷 중 하나이다.

(진행중:0, 흑승:1, 백승:2, 무승부:3)

각 색깔의 돌들이 가로, 세로, 대각선 두 방향으로 5개가 연속되는지 체크.

만약 바둑판의 모든 곳에 바둑돌이 다 놓여져 있다면 무승부로 판단.


이상으로 모든 설명을 마칩니다.

감사합니다.

댓글

  1. 흥미로운 글이었습니다. 따라해보는 것도 재미있네요.
    실력이 금방 늘 것 같습니다.
    앞으로도 재밌는 글 부탁드립니다~

    답글삭제
    답글
    1. 안녕하세요.

      다른사람이 만든 코드라도 이해되면 내 것(지식)이라 생각합니다.

      말씀하신것처럼 그러다보면 실력이 늡니다.

      감사합니다.

      삭제

댓글 쓰기

이 블로그의 인기 게시물

Qt Designer 설치하기

PyQt5 기반 동영상 플레이어앱 만들기