PyQt기반 타자연습게임앱 만들기

이번에 만든 주제는 파이썬 타자연습게임(Typing Game) 입니다.




주요 기능은 아래와 같습니다.

1. 한글, 영어 선택 기능

2. 난이도 선택 기능 (초, 중, 고급)


이 코드를 이해하기 위해 필요한 사전 지식입니다.

1. 파이썬 자료형 (정수, 문자, 튜플, 리스트)

2. 파이썬 모듈 (C++의 #include와 유사)

3. 파이썬 클래스 (상속, 객체변수, 생성자) 및 쓰레드, 동기화

4. PyQt5 클래스 이해 (QPoint, QRect, QWidget, QPainter, QLayoutBox 등)


전체 프로그램 소스는 크게 3개의 모듈로 구성되어 있습니다.

1. map.py (단어 클래스 및 게임 진행 클래스로 구성)
from PyQt5.QtCore import QPointF, QRect
from PyQt5.QtGui import QFont
from threading import Thread, Lock
from random import randint
from time import sleep

# 튜플 단어장
kor = ('문자열', '정수', '리스트', '튜플', '딕셔너리',
      '타입', '출력', '반복문', '변수', '파이썬')
eng = ('input', 'int', 'string', 'type', 'list', 'class',
      'print', 'python', 'tuple', 'for', 'if', 'while',
     'thread', 'random', 'with', '__init__', '__del__')



class CWord:

    def __init__(self, pt, word):
        # 단어 좌표
        self.pt = pt
        # 단어 문자
        self.word = word        


class CMap:

    def __init__(self, parent):
        self.parent = parent
        self.rect = parent.rect()
        self.word = []
        self.thread = Thread(target=self.play)        
        self.bthread = False        
        self.lock = Lock()        

    def __del__(self):
        self.gameOver()

    def gameStart(self, lang, level):
        self.lang = lang
        self.level = level

        self.bthread = True        
        if self.thread.is_alive() == False:
            self.thread = Thread(target=self.play)            
            self.thread.start()        

    def gameOver(self):
        self.bthread = False
        self.word.clear()
        self.parent.update()
        

    def draw(self, qp):
        qp.setFont(QFont('맑은 고딕', 12))
        self.lock.acquire()
        for w in self.word:
            qp.drawText(w.pt, w.word)        
        self.lock.release()

    def createWord(self):  
        
        self.rect= QRect(self.parent.rect())
        
        # 무작위 단어 선정
        str = ''
        if self.lang==0:
            n = randint(0, len(kor)-1)
            str = kor[n]
        else:
            n = randint(0, len(eng)-1)
            str = eng[n]

        # 무작위 좌표 선정
        x = randint(0, self.rect.width()-50)
        y = 0

        cword = CWord(QPointF(x,y), str)
        self.word.append(cword) 


    def downWord(self, speed):      

        i=0
        for w in self.word[:]:
            if w.pt.y() < self.rect.bottom():
                w.pt.setY(w.pt.y()+speed)
                i+=1
            else:
                del(self.word[i])        
        
    def delword(self, str):

        self.lock.acquire()

        i=0
        find = False
        for w in self.word[:]:
            if str == w.word:
                del(self.word[i])
                find = True
                break
            else:
                i+=1
        self.lock.release()

        if find:
            self.parent.update()

    def play(self):

        while self.bthread:

            if randint(1,200) == 1:
                self.lock.acquire()
                self.createWord()
                self.lock.release()

            self.lock.acquire()
            if self.level == 0:
                self.downWord(0.3)
            elif self.level == 1:
                self.downWord(0.5)
            else:
                self.downWord(0.7)
            self.lock.release()

            self.parent.update()
            sleep(0.01)

2. window.py (윈도우 창 클래스, map을 has A 구조 객체 변수로 가짐)
from PyQt5.QtGui import *
from PyQt5.QtCore import *
from PyQt5.QtWidgets import *
import map

class CWidget(QWidget):
    def __init__(self):
        super().__init__()

        self.initUI()

    def initUI(self):

        #컨트롤 레이아웃 박스
        self.vbox = QVBoxLayout()
        self.hbox = QHBoxLayout()
        
        #한글 영어 선택
        self.lang = QComboBox()
        self.lang.addItem('한글')
        self.lang.addItem('영어')
        self.lang.setCurrentIndex(0)

        #난이도
        self.level = QComboBox()
        self.level.addItem('초보자')
        self.level.addItem('중급자')
        self.level.addItem('전문가')
        self.level.setCurrentIndex(0)

        #단어 입력창
        self.edit = QLineEdit()        

        #게임 시작버튼
        self.btn = QPushButton('게임시작')
        self.btn.setCheckable(True)
        self.btn.toggled.connect(self.toggleButton)

        #수평 레이아웃 위젯 추가
        self.hbox.addWidget(self.lang)
        self.hbox.addWidget(self.level)
        self.hbox.addWidget(self.edit)
        self.hbox.addWidget(self.btn)

        self.vbox.addStretch(1)
        self.vbox.addLayout(self.hbox)
        self.setLayout(self.vbox)
        self.setGeometry(100,100, 500,500)
        self.setWindowTitle('파이썬 문법 타자연습')

        self.map = map.CMap(self)

    def closeEvent(self, e):
        self.map.gameOver()

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

    def toggleButton(self, state):
        if state:
            self.map.gameStart(self.lang.currentIndex(),
                              self.level.currentIndex())
            self.btn.setText('게임종료')
            self.lang.setEnabled(False)
            self.level.setEnabled(False)
        else:
            self.map.gameOver()
            self.btn.setText('게임시작')
            self.lang.setEnabled(True)
            self.level.setEnabled(True)

    def keyPressEvent(self, e):
        # 계속 포커스를 가지도록
        self.edit.setFocus()

        # 엔터키 입력시 단어 확인
        if e.key() == Qt.Key_Return:
            self.map.delword(self.edit.text())
            self.edit.setText('')

3. main.py (앱 실행)
import sys
from window import *

QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True)

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

파이썬에서 모듈은 하나의 소스코드 파일이 지나치게 길어지는 것을 막아주며, 코드를 모듈별로 나누어 사용할 수 있는 방법입니다.

그럼 바로 코드 분석에 들어가 보겠습니다.

1. map.py 코드 분석

먼저 해당 파일에 필요한 PyQt5 및 쓰레드 등 모듈을 불러오는 코드입니다.

from PyQt5.QtCore import QPointF, QRect
from PyQt5.QtGui import QFont
from threading import Thread, Lock
from random import randint
from time import sleep

튜플로 한글, 영어 단어를 저장하는 변수 입니다.

# 튜플 단어장
kor = ('문자열', '정수', '리스트', '튜플', '딕셔너리',
      '타입', '출력', '반복문', '변수', '파이썬')
eng = ('input', 'int', 'string', 'type', 'list', 'class',
      'print', 'python', 'tuple', 'for', 'if', 'while',
     'thread', 'random', 'with', '__init__', '__del__')
직접 코드를 수정해 단어를 추가한다면 더 다양한 단어연습이 가능합니다.

CWord 클래스는 좌표와 단어의 문자열을 저장하는 역할을 담당하는 클래스입니다.

class CWord:

    def __init__(self, pt, word):
        # 단어 좌표
        self.pt = pt
        # 단어 문자
        self.word = word
나중에 게임을 관리하는 CMap 클래스에 CWord를 저장하는 리스트를 만들어 단어 클래스의 객체들을 관리합니다.

CMap 클래스는 전체 게임을 관리하는 클래스 입니다.

class CMap:

    def __init__(self, parent):
        self.parent = parent
        self.rect = parent.rect()
        self.word = []
        self.thread = Thread(target=self.play)        
        self.bthread = False        
        self.lock = Lock()        

    def __del__(self):
        self.gameOver()

생성자에서 부모 윈도우를 인자로 넘겨 받은 후, 부모 윈도우의 크기를 이용해 단어가 표시되는 영역을 지정합니다.

또한 CWord 클래스를 관리하는 리스트와 게임을 진행하는 쓰레드(실행흐름)도 생성합니다.

쓰레드에서 사용되는 공유자원에 대한 동기화 처리를 위해 Lock도 생성해 둡니다.

gameStart() 함수는 부모 윈도우에서 호출되며, 단어 언어(한, 영) 종류와 난이도가 전달인자로 넘어옵니다.

    def gameStart(self, lang, level):
        self.lang = lang
        self.level = level

        self.bthread = True        
        if self.thread.is_alive() == False:
            self.thread = Thread(target=self.play)            
            self.thread.start()

또한, 게임 종료 시 재 시작이 가능하도록 쓰레드가 죽었는지 확인하고, 다시 쓰레드를 가동하는 코드를 포함합니다.

gameOver() 함수는 쓰레드 내부 무한루프를 중지하는 변수를 거짓으로 설정해 쓰레드를 정지하고, 단어 리스트를 전체 삭제한 후, 부모 윈도우로 다시 그리기 이벤트를 보냅니다.

    def gameOver(self):
        self.bthread = False
        self.word.clear()
        self.parent.update()

단어 리스트가 모두 삭제되었다면, 화면에서도 사라지게 하기 위함입니다.

draw() 함수는 부모 윈도우의 paintEvent()에서 호출되는 함수이며, 부모의 QPainter 객체를 넘겨 받아, 단어리스트의 단어들을 출력해주는 역할을 담당합니다.

    def draw(self, qp):
        qp.setFont(QFont('맑은 고딕', 12))
        self.lock.acquire()
        for w in self.word:
            qp.drawText(w.pt, w.word)        
        self.lock.release()

부모 윈도우가 그리는 것 보다는 부모의 QPainter 객체를 넘겨 받아 그리기 권한을 위임 받는 형태가 클래스 설계상 더 좋습니다.

또한 자신의 쓰레드에서 단어리스트 공유 자원에 접근하므로, 메인 쓰레드에서 발생하는 그리기 명령과 Lock을 이용해 동기화 처리가 필요합니다.

쓰레드 동기화는 내용이 너무 방대하므로, 간략히 설명하면 내가 공유자원(화장실 변기)을 쓰는 동안 다른 실행흐름(쓰레드)들이 문을 열고 들어와 같이 볼일을 보는 불상사를 막고자 문을 잠그는 행위(acquire), 볼일을 본 후 다른 쓰레드도 이용하도록 문을 해제(release)하는 것입니다.

createWord() 함수는 단어의 글자를 무작위로 선택하고, 단어가 출발하는 지점인 화면 위 x 좌표 또한 무작위로 정해 단어 리스트에 추가하는 기능을 담당합니다.

    def createWord(self):  
        
        self.rect= QRect(self.parent.rect())
        
        # 무작위 단어 선정
        str = ''
        if self.lang==0:
            n = randint(0, len(kor)-1)
            str = kor[n]
        else:
            n = randint(0, len(eng)-1)
            str = eng[n]

        # 무작위 좌표 선정
        x = randint(0, self.rect.width()-50)
        y = 0

        cword = CWord(QPointF(x,y), str)
        self.word.append(cword)

downWord() 함수는 단어리스트에 있는 단어들을 화면 아래로 내려가도록 보여주는 역할을 담당합니다.

    def downWord(self, speed):      

        i=0
        for w in self.word[:]:
            if w.pt.y() < self.rect.bottom():
                w.pt.setY(w.pt.y()+speed)
                i+=1
            else:
                del(self.word[i])

다만 화면의 아래 Y 좌표 보다 크다면, 단어리스트에서 단어를 해야 하겠죠.

delWord() 함수는 부모 윈도우에서 엔터키를 누르면 호출되며, 전달인자로 입력창에 적힌 문자를 넘겨 받아 단어 리스트에 있는지 확인한 후, 해당 단어가 있다면 삭제하는 기능을 담당합니다.

    def delword(self, str):

        self.lock.acquire()

        i=0
        find = False
        for w in self.word[:]:
            if str == w.word:
                del(self.word[i])
                find = True
                break
            else:
                i+=1
        self.lock.release()

        if find:
            self.parent.update()

유심히 봐야 할 부분은 리스트의 삭제시 반복자 인덱스에 문제가 없도록 리스트 슬라이싱기법을 통해 복사본을 생성하여 원본을 삭제하는 부분입니다.

play() 함수는 쓰레드 함수이며, 단어를 생성하고 아래로 좌표를 이동시키는 역할을 담당합니다.

    def play(self):

        while self.bthread:

            if randint(1,200) == 1:
                self.lock.acquire()
                self.createWord()
                self.lock.release()

            self.lock.acquire()
            if self.level == 0:
                self.downWord(0.3)
            elif self.level == 1:
                self.downWord(0.5)
            else:
                self.downWord(0.7)
            self.lock.release()

            self.parent.update()
            sleep(0.01)

단어의 생성은 쓰레드의 호출 간격이 짧아 200분의 1의 확률로 생성되도록 하였으며, 단어의 떨어지는 속도는 난이도에 따라 차등을 두었습니다.

해당 쓰레드는 무한 루프로 구성되어 있으며, 0.01초 주기를 갖고 반복적으로 단어의 생성과 이동을 수행합니다.

2. window.py 코드 분석

먼저 필요한 모듈을 불러옵니다.

from PyQt5.QtGui import *
from PyQt5.QtCore import *
from PyQt5.QtWidgets import *
import map

CWidget 클래스는 Qt의 QWidget 클래스를 상속받은 클래스이며, 메인 윈도우 창을 생성합니다.

class CWidget(QWidget):
    def __init__(self):
        super().__init__()

        self.initUI()

initUI() 함수는 윈도우 창을 수직, 수평 레이아웃 박스를 이용해 컨트롤을 배치하는 역할을 담당합니다.

    def initUI(self):

        #컨트롤 레이아웃 박스
        self.vbox = QVBoxLayout()
        self.hbox = QHBoxLayout()
        
        #한글 영어 선택
        self.lang = QComboBox()
        self.lang.addItem('한글')
        self.lang.addItem('영어')
        self.lang.setCurrentIndex(0)

        #난이도
        self.level = QComboBox()
        self.level.addItem('초보자')
        self.level.addItem('중급자')
        self.level.addItem('전문가')
        self.level.setCurrentIndex(0)

        #단어 입력창
        self.edit = QLineEdit()        

        #게임 시작버튼
        self.btn = QPushButton('게임시작')
        self.btn.setCheckable(True)
        self.btn.toggled.connect(self.toggleButton)

        #수평 레이아웃 위젯 추가
        self.hbox.addWidget(self.lang)
        self.hbox.addWidget(self.level)
        self.hbox.addWidget(self.edit)
        self.hbox.addWidget(self.btn)

        self.vbox.addStretch(1)
        self.vbox.addLayout(self.hbox)
        self.setLayout(self.vbox)
        self.setGeometry(100,100, 500,500)
        self.setWindowTitle('파이썬 문법 타자연습')

        self.map = map.CMap(self)
한, 영 선택 콤보박스와 난이도 콤보박스, 단어 입력 라인에디트, 게임 시작 토글 버튼을 구성해 화면의 하단에 배치하고 상단은 단어가 떨어지는 화면으로 구성합니다.

closeEvent() 함수는 윈도우 창 우 상단 'X 버튼'을 누르면 호출되며, 위젯 종료시 CMap 클래스의 게임 종료 함수를 호출해 쓰레드를 중지시킵니다.

   def closeEvent(self, e):
        self.map.gameOver()

paintEvent() 함수는 윈도우를 새로 그려야 할 필요(화면크기변경, 가려졌다 나타나는 경우 등)가 있을때 마다 호출되는 QWidget의 오버라이딩 함수(재정의)이며, 그리기 권한을 CMap으로 넘기기 위해 CMap 클래스의 draw()함수를 매번 호출합니다.

실제 그림을 그리는 행위는 CMap이 담당하게 되는 것이죠.

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

toggleButton() 함수는 게임 시작버튼이 눌러지면 호출되는 시그널의 슬롯과 연결되어 호출되며, CMap 클래스의 게임시작 함수를 호출해 게임을 시작시킵니다.

게임 시작 시 언어선택과 난이도 선택이 비 활성화 되도록 하고, 종료시 활성화 시킵니다.

   def toggleButton(self, state):
        if state:
            self.map.gameStart(self.lang.currentIndex(),
                              self.level.currentIndex())
            self.btn.setText('게임종료')
            self.lang.setEnabled(False)
            self.level.setEnabled(False)
        else:
            self.map.gameOver()
            self.btn.setText('게임시작')
            self.lang.setEnabled(True)
            self.level.setEnabled(True)

keyPressEvent() 함수는 키보드가 눌러지면 호출되는 QWidget의 오버라이딩(재정의) 함수이며, 키보드를 누를때 마다 단어입력창에 포커스를 주고, 엔터키를 입력하면 입력창의 단어가 화면상에 표시되어 있는 단어인지 체크하는 함수 delWord()를 호출합니다.

    def keyPressEvent(self, e):
        # 계속 포커스를 가지도록
        self.edit.setFocus()

        # 엔터키 입력시 단어 확인
        if e.key() == Qt.Key_Return:
            self.map.delword(self.edit.text())
            self.edit.setText('')


3. main.py 코드 분석

프로그램의 시작점 역할을 하는 메인함수 입니다.

if __name__ == '__main__':   
    app = QApplication(sys.argv)    
    w = CWidget()
    w.show()
    sys.exit(app.exec_())
먼저 모듈로 불러들여진 것이 아님을 체크하는 if 문이 있고 __name__이 메인이면, Qt의 QApplication 객체를 생성합니다.

QApplication 클래스는 Qt GUI 프로그램들이 필수적으로 가져야 할 이벤트 루프를 생성해 처리하는 역할을 주로 담당하며, MFC의 CWinApp와 비슷합니다.

신기한 부분은 QApplication 은 실제 눈이 보이는 QWidget 클래스와 아무런 연결관계를 코드에서 찾을 수 없지만 app.exec() 를 수행하면 윈도우가 실행되어 동작하는 현상이 나타납니다.

MFC는 CWinApp 클래스 내부를 살펴보면 CMainFrame, CView, CDoc 클래스의 객체를 생성해 관계를 만드는 코드를 찾아 볼 수 있지만 Qt는 이런 부분이 없습니다.

하지만 분명한 사실은 QApplication의 객체 생성 시점이 QWidget 보다 늦다면 아래와 같은 에러가 뜹니다.

" QWidget: Must construct a QApplication before a QWidget "

분명히 둘은 무슨 관계를 가지고 있다는 사실이죠.

궁금해 C++ QWidget.cpp 코드를 살펴 보니, QApplication은 qApp 라는 전역 변수를 제공하고 QWidget 코드 내부에서 이를 참조하는 형태로 연결이 이루어져 있네요.

MFC의 theApp 가 생각납니다.


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

pyinstaller로 제작한 실행파일 링크 : 파이썬 타자연습게임

  • 개발 환경
  1. 운영체제 : MS Windows 10 Pro
  2. 개발 언어 : Python 3.7 (32bit), PyQt5 (5.11.3)
  3. 개발 도구 : MS Visual Studio 2017 Pro

감사합니다.

댓글

  1. 이거 파이참으로도 할수있나요?

    답글삭제
  2. 제가 파이참에서 각자 다른텝에 1개씩 복사붙여넣게 했는데 에러가 나요...

    답글삭제
    답글
    1. 파이참에 설치된 파이썬 버전을 확인하고, PyQt5 패키지만 설치되어 있다면 문제는 없습니다. 다만 각 *.py 파일은 같은 프로젝트내에 존재해야 합니다.

      삭제
  3. 파이썬버전이 몇이여야 하나요?

    답글삭제
    답글
    1. 해당 게시물의 의 마지막에 개발환경을 참조 바라며,

      python 3.7 버전입니다. 32, 64bit 상관없으며, 더 상위 버전 또한 마찬가지 입니다.

      삭제

댓글 쓰기

이 블로그의 인기 게시물

Qt Designer 설치하기

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