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개가 연속되는지 체크.
만약 바둑판의 모든 곳에 바둑돌이 다 놓여져 있다면 무승부로 판단.
이상으로 모든 설명을 마칩니다.
감사합니다.




흥미로운 글이었습니다. 따라해보는 것도 재미있네요.
답글삭제실력이 금방 늘 것 같습니다.
앞으로도 재밌는 글 부탁드립니다~
안녕하세요.
삭제다른사람이 만든 코드라도 이해되면 내 것(지식)이라 생각합니다.
말씀하신것처럼 그러다보면 실력이 늡니다.
감사합니다.
우와
답글삭제