PyQt를 이용해 날아오는 적군 피하기 게임

개요

이번 주제는 Python과 PyQt5를 이용한 피하기 게임입니다.

기본 구성은 주인공이 키보드 화살표(상, 하, 좌, 우)로 이동하고, 모든 방향에서 출현하는 적을 피하는 게임입니다.

지뢰라는 아군의 무기를 도입해 스페이스 키를 누르면 동그란 원이 생기고 적으로부터 1회의 방어막을 생성합니다. (쿨타임 3초)

가끔씩 등작하는 "갈색 적군" 은 아군과 접촉하면 모든 적이 한번에 사라집니다.

 

[실행 화면]

PyGame을 활용해 더 비주얼하고 멋진 게임 코드를 작성할 수도 있지만, 여러분이 개발 가능한 영역을 더 확장(유틸리티, 시스템소프트웨어, 네트워크 등)하기 좋은 Qt를 배우는 것을 권장하고 싶습니다.

예제를 작성시 아래 사항들에 대해 주의를 기울여 코드를 작성해 보았습니다.

  • 객체 지향적으로 만들자
  • 화면 갱신을 최소화해 CPU 점유율을 낮추자

 

소스코드

그럼 소스코드를 한번 살펴보겠습니다.

게임은 총 3개의 파이썬 파일 (main.py, game.py, unit.py) 로 구성되어 있습니다.

 

unit.py 파일

이 파일은 Unit, Enemy, My 3개의 Class 선언 입니다.

from PyQt5.QtCore import Qt, QRectF
from PyQt5.QtGui import QColor

class Unit:
    def __init__(self, r=QRectF(), c=QColor()):
        self.rect = r
        self.color = c

class Enemy(Unit):
    def __init__(self, dir=0, type=0, r=QRectF(), c=QColor()):
        super().__init__(r, c)
        # 적 방향 0:Left, 1:Up, 2:Right, 3:Down
        self.dir = dir
        # 적 종류 0:적, 1:모든적 삭제, 2:필요시 구현
        self.type = type
        self.isDead = False

    def moveUpdate(self, spd=2.0):
        if self.dir == 0:
            self.rect.adjust(spd, 0, spd, 0)
        elif self.dir == 1:
            self.rect.adjust(0, spd, 0, spd)
        elif self.dir == 2:
            self.rect.adjust(-spd, 0, -spd, 0)
        else:
            self.rect.adjust(0, -spd, 0, -spd)

class My(Unit):
    def __init__(self, r=QRectF(), c=QColor()):
        super().__init__(r, c)
        # 주인공 방향 0:Left, 1:Up, 2:Right, 3:Down
        self.dir = [False for _ in range(4)]
        self.hp = 10
        #지뢰 쿨타임, 지뢰
        self.bMine = False
        self.mine_cool = 0
        self.mine = Unit()

    def keyUpdate(self, key, isPress=True):
        if key == Qt.Key_Left:
            self.dir[0] = isPress
        if key == Qt.Key_Up:
            self.dir[1] = isPress
        if key == Qt.Key_Right:
            self.dir[2] = isPress
        if key == Qt.Key_Down:
            self.dir[3] = isPress

        if key == Qt.Key_Space and self.bMine==False and self.mine_cool==0:
            self.bMine = not self.bMine
            self.mine_cool = 300 # 3초
            size = self.rect.width()*3
            cpt = self.rect.center()
            x = cpt.x()-size/2
            y = cpt.y()-size/2
            self.mine = Unit( QRectF(x, y, size, size), QColor(128,128,128,128) )

    def moveUpdate(self, spd = 2.0):
        if self.dir[0]: # left
            self.rect.adjust(-spd, 0, -spd, 0)
        if self.dir[1]: # up
            self.rect.adjust(0, -spd, 0, -spd)
        if self.dir[2]: # right
            self.rect.adjust(spd, 0, spd, 0)
        if self.dir[3]: # down
            self.rect.adjust(0, spd, 0, spd)

  • Unit Class

    • 속성 : 사각형 (QRectF), 색상 (QColor)
    • 용도 : 아군, 적군의 공통 속성을 뽑아낸 기본 클래스

Unit 클래스는 주인공과 적군의 기본 클래스 (Base Class)입니다. 추상 베이스 클래스 (Abstract Base Class, 이하 ABC)로 구성하면 좋겠지만 코드 간단화를 위해 그냥 일반 클래스로 작성하였습니다.

간략히 부연 설명하면, ABC는 클래스 상속관계에서만 사용되며 객체를 인스턴스화 시킬수 없는 추상적인 존재입니다.

  • Enemy Class : Unit 상속

    • 속성 : 적군 등장 방향 (int), 적군 타입 (int), 죽음여부 (bool)
    • 함수 : moveUpdate()
    • 용도 : Unit에서 상속받은 적군 클래스

Enemy 클래스는 Unit에서 상속받은 적군 클래스입니다.

적군이 등장하는 방향 (0:Left, 1:Up , 2:Right, 3:Down) 과 타입 (0:일반적군, 1:모든적 소멸 등), 아군과 충돌 or 화면밖으로 나갔을때 상태를 저장하는 변수를 가집니다.

moveUpdate() 메서드는 QRect의 adjust() 함수로 사각형을 이동시키는 역할을 수행합니다. adjust(x, y, x1, y1) 함수의 4개 전달인자 중 x, y는 사각형 왼쪽 윗점의 오프셋을, x1, y1은 오른쪽 아래점의 오프셋을 의미하며 만약 적군이 왼쪽에서 오른쪽으로 가는 친구라면 adjust(5, 0, 5, 0) 은 사각형을 오른쪽으로 5픽셀 이동하게 됩니다.

  • My Class : Unit 상속

    • 속성 : 이동 방향 (int), HP (int), 지뢰 (Unit), 지뢰여부, 지뢰쿨타임
    • 함수 : keyUpdate(), moveUpdate()
    • 용도 : Unit에서 상속받은 아군 클래스

My 클래스는 Unit에서 상속받은 주인공 클래스입니다.

키보드 화살표키(상, 하, 좌, 우)의 눌러짐을 bool 타입으로 저장해 아군 사각형 좌표를 이동시키며 키가 눌러졌을때 바로 이동하는 것이 아니라 키보드 방향키의 눌러짐 여부 상태만 저장합니다.

그 이유는 향후 쓰레드에서 My 클래스 객체의 방향을 감지해 부드럽게 이동을 구현하고, 대각선 방향 (방향키 2개가 같이 눌러진 경우) 이동도 처리하기 위함입니다.

 

game.py 파일

실제 게임에 대한 모든 처리를 담당하는 Game 클래스 하나로 구성되어 있습니다.

여기서 아군, 적군을 생성하고 이동시키며 게임을 진행시켜 나가는 역할을 담당합니다.

from PyQt5.QtCore import Qt, QSizeF, QObject, pyqtSignal, QRectF
from PyQt5.QtGui import QPainter, QFont, QFontMetrics, QBrush, QColor
from unit import Unit, Enemy, My
from threading import Thread
import time
import random

class Game(QObject):

    update_widget = pyqtSignal(QRectF)
    game_over = pyqtSignal()

    def __init__(self, w):        
        super().__init__()
        self.parent = w
        self.rect = QRectF(w.rect())
        self.bRun = False

        # 적군 저장 리스트
        self.e = []
        # 점수
        self.score = 0
        # FontMetrics
        self.font = QFont('arial', 15);        

        # 시그널 처리
        self.update_widget.connect(self.parent.redraw)
        self.game_over.connect(self.parent.gameOver)

    def startGame(self):
        cpt = self.rect.center()
        size = QSizeF(30,30)
        self.my = My(QRectF(cpt, size), QColor(0,255,0))
        self.bRun = True
        self.t = Thread(target=self.threadFunc)
        self.t.start()

    def endGame(self):
        self.bRun = False

    def isStart(self):
        return self.bRun    

    def keyPressed(self, key):        
        self.my.keyUpdate(key, True)        

    def keyReleased(self, key):
        self.my.keyUpdate(key, False)

    def draw(self, qp):
    	self.rect = QRectF(self.parent.rect())
        # 게임 시작 전
        if self.bRun==False:
            font = QFont('arial', 20)
            qp.setFont(font)
            qp.drawText(self.rect, Qt.AlignCenter, 'Press any key to start!')
        else:
            # 주인공, 지뢰 그리기
            b = QBrush(self.my.color)
            qp.setBrush(b)            
            qp.setFont(self.font)
            qp.drawRect(self.my.rect)
            if self.my.bMine:
                b = QBrush(self.my.mine.color)
                qp.setBrush(b)
                qp.drawEllipse(self.my.mine.rect)            

            # 적군 그리기
            for e in self.e:
                b = QBrush(e.color)
                qp.setBrush(b)
                qp.drawRect(e.rect)

            # 쿨타임, 점수, 체력
            qp.drawText(self.rect, Qt.AlignTop|Qt.AlignRight, f'Cool:{self.my.mine_cool}')
            qp.drawText(self.rect, Qt.AlignTop|Qt.AlignLeft, f'Score:{self.score}')
            qp.drawText(self.my.rect, Qt.AlignCenter, str(self.my.hp))


    def createEnemy(self):
        r = random.randint(1, 100)
        if r>=1 and r<=10: # 10%확률로 생성
            dir = random.randint(0,3)
            size = 20
            if dir == 0: # from left
                x = self.rect.left()-size
                y = random.randint(self.rect.top(), self.rect.bottom()-size)
            elif dir == 1: # from top
                x = random.randint(self.rect.left(), self.rect.right()-size)
                y = self.rect.top()-size
            elif dir == 2: # from right
                x = self.rect.right()
                y = random.randint(self.rect.top(), self.rect.bottom()-size)
            else: # from bottom
                x = random.randint(self.rect.left(), self.rect.right()-size)
                y = self.rect.bottom()

            rect = QRectF(x, y, size, size)

            # 적 종류 0:적, 1:모든적 삭제, 2:필요시 구현
            r = random.randint(1,100)
            if(r==1): # 1%
                type = 1
                color = QColor(200, 127, 39)
            else:
                type = 0
                color = QColor(255, 0, 0)
            
            self.e.append( Enemy(dir, type, rect, color ) )

    def moveEnemy(self):
        for e in self.e:
            e.moveUpdate()
            # 적군이 화면 밖으로 나갔는지
            if self.rect.intersects(e.rect) == False:
                e.isDead = True
            # 적군이 나와 충돌했는지
            elif self.my.rect.intersects(e.rect):
                if e.type==0:
                    e.isDead = True
                    self.my.hp -= 1
                else:
                    self.e.clear()
                    self.update_widget.emit(self.rect)
                    break
            # 적군이 나의 지뢰와 충돌했는지
            elif self.my.bMine and self.my.mine.rect.intersects(e.rect):
                e.isDead = True
                self.my.bMine = False
                self.update_widget.emit(self.my.mine.rect)        

    def update(self):
        # 화면갱신 (아군)
        self.update_widget.emit(self.my.rect)
        # 화면갱신 (점수, 쿨타임)
        fm = QFontMetrics(self.font)
        rect = fm.boundingRect(self.rect.toAlignedRect(), Qt.AlignTop|Qt.AlignRight, f'Cool:{self.my.mine_cool}')
        self.update_widget.emit(QRectF(rect))
        rect = fm.boundingRect(self.rect.toAlignedRect(), Qt.AlignTop|Qt.AlignLeft, f'Score:{self.score}')
        self.update_widget.emit(QRectF(rect))

        # 화면 갱신 (지뢰)
        if self.my.bMine:
            self.update_widget.emit(self.my.mine.rect)                
        # 화면 갱신 (적군)
        for e in self.e:
            self.update_widget.emit(e.rect)

        # 적군 삭제 (충돌 or 화면밖)
        before = len(self.e)
        self.e = [e for e in self.e if not e.isDead]
        after = len(self.e)
        self.score += before-after
        

    def threadFunc(self):
        while self.bRun:
            # 주인공 이동
            self.my.moveUpdate()

            #  적군 생성, 추가
            self.createEnemy()

            # 적군 이동
            self.moveEnemy()

            # 화면 갱신 (주인공)
            self.update()

            # 지뢰 쿨타임
            if self.my.mine_cool>0:
                self.my.mine_cool-=1

            # 게임종료
            if self.my.hp<=0:                
                self.game_over.emit()
                break

            time.sleep(0.01)

 

Game 클래스의 각 메서드(멤버함수)별 코드를 분석해 보겠습니다.

  • __init__() 함수 [라인 13~28]

    • 전달인자로 넘어오는 QWidget 정보를 전달받아 변수에 저장
    • 쓰레드 내부 무한루프 제어용 bool 타입 변수 생성

    • 생성될 적군들 객체 저장용 리스트 및 점수 변수 생성
    • 화면 갱신, 게임 종료 처리용 사용자 정의(User Defined) 시그널 생성, 차후 QWidget에 전송
  • startGame(), endGame() 함수 [라인 30~39]

    • 게임 시작 시(아무키나 누르면) 호출되며, 아군을 화면의 중앙에 생성하고 Thread생성

    • 게임 종료시 Thread 동작 여부를 저장하는 bRun을 False로 만들고 쓰레드 탈출

  • isStart() 함수 [라인 41~42]

    • 게임 진행여부를 뜻하는 bRun의 True, False 값을 리턴

    • 파이썬 클래스는  C++의 접근제한 키워드 (public, protected, private) 가 없으니 그냥 변수에 접근해도 무방하지만 습관이 무섭습니다. 
  • keyPressed(), Released() 함수 [라인 44~48]

    • QWidget에서 키보드 시그널 발생시 호출되는 함수

    • 아군의 키보드 상태를 업데이트
  • draw() 함수 [라인 50~77]

    • QWidget에서 paintEvent() 발생시 호출되는 함수, QPainter 객체(qp)를 전달인자로 받음
    • Game 클래스는 QWidget에서 상속받지 않았으므로, 그림을 그리기 위해서는 QWidget의 painterEvent() 에서 생성한 객체를 넘겨받아 그리는 원리
    • 게임 시작전에는 "Press any key to start"를 출력
    • 게임 중에는 아군, 적군, 지뢰, 표시정보(점수, 체력, 쿨타임) 을 그림
  • createEnemy() 함수 [라인 80~109]

    • 적군을 생성하는 함수, 쓰레드에서 0.01초 주기로 호출됨

    • Random을 이용, 적군 생성 갯수와 방향, 타입, 초기위치를 결정하고 생성된 적군은 리스트에 저장 (self.e = [e1, e2, e3, ... en] )
  • moveEnemy() 함수 [라인 111~130]

    • 적군을 이동, 삭제 여부를 담당, 쓰레드에서 0.01초 주기로 호출됨

    • QRectF의 adjust() 함수를 이용, 적군 이동 방향에 맞춰 이동
    • QRectF의 intersects() 함수를 이용, 적군, 아군, 지뢰의 사각형좌표가 교집합이 구성되는지 체크해 삭제 여부 결정

[직사각형 교집합 체크, 출처 : Qt Document]
    • 적군 아군의 충돌이나, 화면을 나갔을때 리스트에서 바로 삭제하지 않는 이유는 화면 갱신시 리스트 Comprehension을 통해 일괄 삭제하기 때문.

    • Fluent Python책 저자 "루시아누 하말류"는 List Comprehension을 지능형 리스트라고 표현. 좋은 책입니다.

  • update() 함수 [라인 132~153]

    • 게임 화면 갱신 (아군, 적군, 지뢰 등)하는 함수, 쓰레드에서 0.01초 주기로 호출됨

    • 아군, 적군, 지뢰는 좌표만 저장, 갱신하므로 이를 실제 화면에 출력하는 역할을 담당

    • Game 객체를 생성하는 QWidget으로 사용자 정의 시그널 (라인 10, update_signal) 을 전송

    • QWidget의 전체 영역을 갱신(X), 비효율적이므로 아군, 적군, 지뢰등 새로 그려져야할 QRectF 타입의 영역만 갱신해 효율성을 극대화

    • 적군의 속성 중 isDead (bool 속성) 가 False인 적군 (화면 안 and 아군과 충돌 X) 을 제외하고 삭제

    • 적군 삭제 전 갯수와 삭제 후 갯수를 비교해 점수에 반영

    • 삭제전 적군 10, 삭제후 적군 8이면 10-8 = 2이므로 2점 증가

  • threadFunc() 함수 [라인 156~179]

    • 게임이 시작될때 생성되는 Thread 객체 (별도의 실행흐름) 가 호출하는 함수

    • 0.01초 주기로 무한루프를 반복하며 아군이동, 적군생성 및 이동, 화면 갱신, 쿨타임시간 감소, 게임종료 감지를 수행
    • 게임종료 (아군HP 0 이하) 시 무한루프를 탈출하며, QWidget으로 game_over (사용자 정의 시그널) 전송

 

main.py 파일

QWidget에서 상속받은 Form 클래스를 이용해 위젯 창(윈도우)을 생성하고 Game 객체 생성을 통해 게임을 시작하는 메인함수 역할을 담당합니다.

소스코드를 살펴보겠습니다.

from PyQt5.QtWidgets import QApplication, QWidget, QMessageBox
from game import *
import sys

# 4k monitor
QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True)


class Form(QWidget):
    def __init__(self):
        super().__init__()
        self.setWindowTitle('Avoid Game (Ocean Coding School)')
        self.game = Game(self)

    def redraw(self, rect):
        # 영역 확대 (잔상 방지)
        gap = rect.width()*0.2
        rect.adjust(-gap, -gap, gap, gap)
        self.update(rect.toAlignedRect())

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

    def keyPressEvent(self, e):
        if self.game.isStart() == False:
            self.game.startGame()
            self.update()
        else:
            self.game.keyPressed(e.key())  

    def keyReleaseEvent(self, e):
        if self.game.isStart():
            self.game.keyReleased(e.key())

    def closeEvent(self, e):
        self.game.endGame()

    def gameOver(self):
        result = QMessageBox.information(self, 'Game Over', 'Retry(Yes) or Exit(No)', QMessageBox.Yes | QMessageBox.No)
        if result == QMessageBox.Yes:
            del(self.game)
            self.game = Game(self)
        else:
            self.close()

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

그럼 Form 클래스의 각 메서드(멤버함수)별 코드를 분석해 보겠습니다.

  • __init__() 함수 [라인 10~13]

    • QWidget에서 상속받은 클래스이므로 부모(슈퍼클래스)의 생성자 호출
    • game.py에 정의된 Game 클래스 객체 생성
  • redraw() 함수 [라인 15~19]

    • game.py에 정의된 Game 클래스에서 만든 사용자 시그널(Signal) update_widget이 발생하면 호출되는 슬롯(Slot) 함수 (game.py 27번 라인 참조)
    • update_widget은 Game 클래스 쓰레드에서 아군, 적군, 지뢰 등 화면갱신이 필요할 때마다 전송(Emit) 됨
    • 이때 새로 그려야 할 사각형 영역을 약간 확대시켜 (잔상 방지) QWidget의 update() 함수에 전달 후 Repaint를 수행 (전체 새로 그림 X, 필요한 부분만 O)

    • Qt는 객체간 이벤트 처리를 위해 Signal, Slot 이라는 방식을 사용
  • paintEvent() 함수 [라인 21~25]

    • QWidget 클래스(부모)에 선언된 함수 재정의 (Overriding) 
    • 위젯을 새로그러야 할 필요 (위젯이 다른창에 가려진 후 다시 등장, 리사이즈 등)가 있을 때 마다 호출되는 함수
    • QPainter 객체를 생성해 Game 클래스의 draw() 함수로 전달, 실제 게임을 그리는 행동은 Game 클래스로 위임
  • keyPressEvent(), Release() 함수 [라인 27~36]

    • QWidget 클래스(부모)에 선언된 함수 재정의 (Overriding)
    • 위젯에 키보드 눌러짐이 발생할 때 마다 호출되는 함수

    • 전달인자로 넘어오는 QKeyEvent 객체에 누른 키 정보가 담겨 있으며, paintEvent() 와 마찬가지로 Game 클래스의 keyPress, keyRelese 함수로 전달해 키처리 위임
  • closeEvent() 함수 [라인 38~39]

    • QWidget 클래스(부모)에 선언된 함수 재정의 (Overriding)
    • 위젯이 종료될 때 호출되는 함수

    • 만약 Game 클래스의 객체가 생성되어 쓰레드가 가동중인 상황 (게임 진행중 종료)이면 쓰레드 종료를 처리
  • gameOver() 함수 [라인 38~39]

    • game.py에 정의된 Game 클래스에서 만든 사용자 시그널(Signal) game_over가 발생하면 호출되는 슬롯(Slot) 함수 (game.py 28번 라인 참조)
    • 아군이 체력이 0 이하가 되면 호출되며 모달 대화상자를 띄워 진행여부를 결정

[QMessageBox 대화상자]

 

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

코드를 실행하기 위해서는 3개의 파일을 한 프로젝트에 포함시킨 후, main.py를 시작파일로 설정하면 됩니다. 

 의문점은 댓글로 남겨주시면 답변드리겠습니다. 감사합니다.

 

[개발환경]

  • Windows 10 pro, Python 3.7(64bit)
  • MS Visual Studio 2017
  • PyQt5 5.15.2, Pyinstaller 4.2(dev 0)

댓글

이 블로그의 인기 게시물

Qt Designer 설치하기

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