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를 시작파일로 설정하면 됩니다.
-
Pyinstaller로 제작한 실행파일 링크 : 피하기게임
의문점은 댓글로 남겨주시면 답변드리겠습니다. 감사합니다.
[개발환경]
- Windows 10 pro, Python 3.7(64bit)
- MS Visual Studio 2017
- PyQt5 5.15.2, Pyinstaller 4.2(dev 0)
댓글
댓글 쓰기