파이썬 예제 (슈팅게임)

이번에 만든 주제는 파이썬 + PyQt5로 구현한 슈팅게임(Shooting Game) 입니다.
 




주요 기능으로,

1. 키보드를 이용한 아군 이동

2. 적군 랜덤 생성 및 이동

3. 적군 아군 충돌 감지

4. 적군 아군 총알 충돌 감지


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

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

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

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

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

PyQt5 설치 및 소개 링크

 

소스코드

전체 프로그램 소스는 크게 3개의 파이썬 파일(모듈)로 이루어져 있습니다.

1. map.py (아군, 적군 클래스 및 게임을 진행하는 CMap클래스로 구성) 

아래는 map.py 파일의 전체 소스코드 입니다.

from PyQt5.QtCore import *
from PyQt5.QtGui import *
from random import *
import os
import threading
import time


class CUnit:
    size = 10
    def __init__(self):
        self.rect = QRect() 
        self.color = QColor(0,0,0)


class CMy(CUnit):
    def __init__(self):
        super().__init__()
        self.hp = 10        
        self.color.setRgb(0,0,255,255)
        self.bullet = []
        

class CEnemy(CUnit):
    def __init__(self):
        super().__init__()
        self.color.setRgb(255,0,0,255)

class CMap:

    def __init__(self, parent):
        self.parent = parent
        self.my = CMy()
        self.enemy = []
        self.lockE = threading.Lock()
        self.lockB = threading.Lock()
        self.b = True

        self.initGame()

    def __del__(self):        
        pass

    def initGame(self):

        rect = self.parent.rect()
        size = CUnit.size

        self.my.rect.setRect(rect.left()+size, rect.height()/2, size, size)

        t1 = threading.Thread(target=self.createEnemy)
        t2 = threading.Thread(target=self.moveEnemy)
        t3 = threading.Thread(target=self.moveBullet)

        t1.start()
        t2.start()
        t3.start()

    def exitGame(self):
        self.b = False

    def createEnemy(self):

        while self.b:
        
            if randint(1,10) == 1:
                rect = self.parent.rect()
                size = CUnit.size

                enemy = CEnemy()
                y = randint(0, rect.height())
                enemy.rect.setRect(rect.right(), y, size, size)
                self.lockE.acquire()
                self.enemy.append(enemy)            
                self.lockE.release()

            time.sleep(0.1)

    def moveEnemy(self):

        while self.b:
            
            self.lockE.acquire()
            k=0
            for i in self.enemy[:]:
                if i.rect.right() < 0:
                    del(self.enemy[k])
                elif i.rect.intersects(self.my.rect):
                    del(self.enemy[k])
                    self.my.hp-=1
                else:
                    self.enemy[k].rect.adjust(-1,0,-1,0)
                    k+=1                

            self.lockE.release()

            self.parent.update()
            time.sleep(0.01)

    def moveBullet(self):
        
        while self.b:
            k=0           
            
            self.lockB.acquire()

            for i in self.my.bullet[:]:
                if i.left() > self.parent.rect().right():
                    del(self.my.bullet[k])
                else:
                    e=0
                    hit = False
                    self.lockE.acquire()
                    for j in self.enemy[:]:
                        if j.rect.intersects(i):
                            hit = True
                            del(self.my.bullet[k])
                            del(self.enemy[e])
                            break
                        else:
                            e+=1
                    self.lockE.release()

                    if hit:
                        break
                    else:
                        self.my.bullet[k].adjust(1,0,1,0)
                        k+=1

            self.lockB.release()

            self.parent.update()
            time.sleep(0.01)

    def draw(self, qp):

        qp.drawText(self.parent.rect(),
                   Qt.AlignLeft | Qt.AlignTop, 'HP:'+str(self.my.hp))

        qp.setBrush(self.my.color)
        qp.drawRect(self.my.rect)

        self.lockE.acquire()
        for i in self.enemy:
            qp.setBrush(i.color)
            qp.drawRect(i.rect)
        self.lockE.release()

        self.lockB.acquire()
        for i in self.my.bullet:
            qp.setBrush(QColor(255,255,255))
            qp.drawEllipse(i)
        self.lockB.release()

    def keyDown(self, key):
        
        speed = 5
        if key == Qt.Key_Left:
            self.my.rect.adjust(-speed,0,-speed,0)
        elif key == Qt.Key_Right:
            self.my.rect.adjust(speed,0,speed,0)
        elif key == Qt.Key_Up:
            self.my.rect.adjust(0, -speed,0,-speed)
        elif key == Qt.Key_Down:
            self.my.rect.adjust(0, speed,0, speed,)
        elif key == Qt.Key_Space:
            rect = QRect(self.my.rect)           
            self.my.bullet.append(rect)        

        self.parent.update()

2. window.py (메인 윈도우 생성, 키보드 시그널 처리 등)

아래는 window.py 파일의 전체 코드 입니다.
from map import * 
from PyQt5.QtWidgets import *

class CWidget(QWidget):

    def __init__(self):       
        super().__init__()
        
        self.initUI()

    def __del(self):
        pass

    def initUI(self):          
        self.setGeometry(100,100,600,300)
        self.setWindowTitle('슈팅 게임') 
        self.setFixedSize(self.rect().size())
        
        self.map = CMap(self)
       

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


    def keyPressEvent(self, e):        
        self.map.keyDown(e.key())

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

3. main.py (메인 함수, QApplication 생성, 실행)

아래는 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_())


전체 코드는 위와 같으며, 코드 분석에 들어가겠습니다.

먼저 map.py 파일 분석입니다.

게임상에서 적군과 아군의 유사한 부분(둘다 사각형, 색상을 가짐)을 CUnit 이라는 클래스로 정의 합니다.
class CUnit:
    size = 10
    def __init__(self):
        self.rect = QRect() 
        self.color = QColor(0,0,0)


class CMy(CUnit):
    def __init__(self):
        super().__init__()
        self.hp = 10        
        self.color.setRgb(0,0,255,255)
        self.bullet = []
        

class CEnemy(CUnit):
    def __init__(self):
        super().__init__()
        self.color.setRgb(255,0,0,255)
아군 클래스 CMy 는 CUnit에서 상속받은 클래스이며, 자신만의 특징인 체력과 총알이라는 특징을 객체 변수로 추가 합니다.

적군 클래스 CEnemy 는 역시 CUnit에서 상속받았으며, 색상만 빨간색으로 정의합니다.

setRgb()는 순서대로 Red, Green, Blue 값 이며, 마지막은 투명도를 뜻합니다.


CMap 클래스는 게임 전체를 관리하는 목적의 클래스 입니다.
class CMap:

    def __init__(self, parent):
        self.parent = parent
        self.my = CMy()
        self.enemy = []
        self.lockE = threading.Lock()
        self.lockB = threading.Lock()
        self.b = True

        self.initGame()

    def __del__(self):        
        pass

먼저 생성자 전달인자로 부모(QWidget) 윈도우를 받아오며, 아군 및 다수의 적군을 관리할 리스트를 생성합니다.

적군의 생성과 소멸이 쓰레드에서 이루어 지므로 이를 동기화 하기위한 lockE와 총알의 동기화를 위한 lockB를 생성합니다.

마지막으로 initGame()함수를 호출하는 행동을 생성자에서 수행합니다.

initGame() 함수는
    def initGame(self):

        rect = self.parent.rect()
        size = CUnit.size

        self.my.rect.setRect(rect.left()+size, rect.height()/2, size, size)

        t1 = threading.Thread(target=self.createEnemy)
        t2 = threading.Thread(target=self.moveEnemy)
        t3 = threading.Thread(target=self.moveBullet)

        t1.start()
        t2.start()
        t3.start()

    def exitGame(self):
        self.b = False
부모 윈도우 크기를 이용해 아군을 배치하고, 적생성, 적 이동, 총알 이동을 처리할 3개의 실행흐름(쓰레드)를 생성해 동작시킵니다.

exitGame() 함수는 게임 종료시 각 쓰레드의 무한루프를 종료시키기 위해 self.b 라는 bool 타입 변수를 False로 설정합니다.

createEnemy()함수는 쓰레드 함수이며, 적군을 생성시키는 역할을 담당합니다.
def createEnemy(self):

        while self.b:
        
            if randint(1,10) == 1:
                rect = self.parent.rect()
                size = CUnit.size

                enemy = CEnemy()
                y = randint(0, rect.height())
                enemy.rect.setRect(rect.right(), y, size, size)
                self.lockE.acquire()
                self.enemy.append(enemy)            
                self.lockE.release()

            time.sleep(0.1)

매번 일정시간마다 적이 생성되면 재미가 없으니, 매 01.초 주기로 10분의 1의 확률을 적용해 적이 생성되며, 적군의 생성 위치 중 X좌표는 화면 오른쪽 끝으로, Y좌표는 화면 높이에서 랜덤한 위치를 정하도록 하였습니다.

적이 생성되는 동안 적군이 아군의 총알에 소멸되어 사라질 수 있으므로 동기화 처리를 해 두었습니다.

moveEnemy() 함수 역시 쓰레드 함수이며, 생성된 적군의 이동 및, 화면에서 사라진 경우 삭제하는 역할을 담당합니다.
   def moveEnemy(self):

        while self.b:
            
            self.lockE.acquire()
            k=0
            for i in self.enemy[:]:
                if i.rect.right() < 0:
                    del(self.enemy[k])
                elif i.rect.intersects(self.my.rect):
                    del(self.enemy[k])
                    self.my.hp-=1
                else:
                    self.enemy[k].rect.adjust(-1,0,-1,0)
                    k+=1                

            self.lockE.release()

            self.parent.update()
            time.sleep(0.01)

또한 적군과 아군의 충돌 또한 감지해야 하므로 QRect의 intersect (교집합) 함수를 이용해 처리하고 있습니다.

적군의 이동은 QRect의 adjust 함수를 이용해 X좌표를 -1씩 이동하는 방식으로 구현되어 있습니다.

moveBullet() 함수는 코드가 조금 어려워 보입니다.
   def moveBullet(self):
        
        while self.b:
            k=0           
            
            self.lockB.acquire()

            for i in self.my.bullet[:]:
                if i.left() > self.parent.rect().right():
                    del(self.my.bullet[k])
                else:
                    e=0
                    hit = False
                    self.lockE.acquire()
                    for j in self.enemy[:]:
                        if j.rect.intersects(i):
                            hit = True
                            del(self.my.bullet[k])
                            del(self.enemy[e])
                            break
                        else:
                            e+=1
                    self.lockE.release()

                    if hit:
                        break
                    else:
                        self.my.bullet[k].adjust(1,0,1,0)
                        k+=1

            self.lockB.release()

            self.parent.update()
            time.sleep(0.01)

코드가 다른 함수에 비해 긴 이유는 총알 하나가 이동할때 적군과 접촉했는지를 모든 적군에 대해 검사해야하기 때문입니다.

먼저 총알이 아무에게도 닿지 않고 화면 오른쪽으로 나가면 삭제, 근데 적군 모두와 비교해 어떤 적군과 닿았다면 총알과 적군을 모두 삭제하는 방식입니다.

총알이 닿은 대상이 없다면 X좌표를 증가시켜 이동을 구현합니다.

주의해야 할 점은 총알이나 적군의 삭제시 리스트 원본이 아닌 복사본을 이용해 비교해 가며 삭제해야 반복문 수행 도중 해당 배열의 크기가 변경되는 일을 막을 수 있습니다.

draw()함수는 QWidget에서 호출되는 함수이며, QPaint객체를 전달인자로 넘겨받아 아군, 적군, 총알을 그려주는 역할을 수행합니다.
def draw(self, qp):

        qp.drawText(self.parent.rect(),
                   Qt.AlignLeft | Qt.AlignTop, 'HP:'+str(self.my.hp))

        qp.setBrush(self.my.color)
        qp.drawRect(self.my.rect)

        self.lockE.acquire()
        for i in self.enemy:
            qp.setBrush(i.color)
            qp.drawRect(i.rect)
        self.lockE.release()

        self.lockB.acquire()
        for i in self.my.bullet:
            qp.setBrush(QColor(255,255,255))
            qp.drawEllipse(i)
        self.lockB.release()

다만 메인 윈도우창에서 수행되는 쓰레드의 그리기 함수호출과 다른 쓰레드(실행흐름)들 간 동기화를 시켜주어야 합니다.

keyDown()함수는 메인 윈동우에서 키보드 시그널 발생시 호출되는 함수이며, 키보드의 화살표 방향에 따라 아군의 좌표를 이동시키고, 스페이스 키가 눌러졌으면 총알을 생성시키는 역할을 담당합니다.
    def keyDown(self, key):
        
        speed = 5
        if key == Qt.Key_Left:
            self.my.rect.adjust(-speed,0,-speed,0)
        elif key == Qt.Key_Right:
            self.my.rect.adjust(speed,0,speed,0)
        elif key == Qt.Key_Up:
            self.my.rect.adjust(0, -speed,0,-speed)
        elif key == Qt.Key_Down:
            self.my.rect.adjust(0, speed,0, speed,)
        elif key == Qt.Key_Space:
            rect = QRect(self.my.rect)           
            self.my.bullet.append(rect)        

        self.parent.update()

다음은 window.py 파일 코드 분석입니다.

해당 파일은 메인 윈도우 창을 만들어 띄우는 역할을 담당합니다.

CWidget클래스는 Qt의 QWidget 클래스에서 상속받아 생성되며, 아래 코드는 해당 클래스의 생성자와 소멸자 함수 입니다.
class CWidget(QWidget):

    def __init__(self):       
        super().__init__()
        
        self.initUI()

    def __del(self):
        pass

생성자에서 슈퍼클래스(부모)의 생성자를 호출하고, initUI() 라는 사용자 정의 함수를 호출하고 있습니다.

소멸자는 별 내용이 없어 함수정의 구현만 하고 코드는 pass를 통해 아무 행동도 하지 않도록 처리합니다.

initUI() 함수는 윈도우창의 시작 위치와 크기를 정의하고, setfixedSize()함수를 통해 윈도우 크기 변경이 불가하도록 설정하였습니다.
   def initUI(self):          
        self.setGeometry(100,100,600,300)
        self.setWindowTitle('슈팅 게임') 
        self.setFixedSize(self.rect().size())
        
        self.map = CMap(self)
마지막으로 map.py에 정의된 CMap클래스의 변수를 생성해 객체 변수로 정의합니다.


paintEvent()함수는 부모인 QWidget 클래스의 PaintEvent()함수를 재정의하여 윈도우를 다시 그려야 할 필요가 있을때 마다 CMap의 draw함수를 호출하도록 해 줍니다.
   def paintEvent(self, e):
        qp = QPainter()
        qp.begin(self)
        self.map.draw(qp)
        qp.end()     

실제 그림은 CMap의 draw()가 그려내고, 여기서는 그림을 그리는 클래스인 QPainter의 qp 객체만 넘겨서 대신 그리도록 합니다.

keyPressEvent() 함수 또한 부모로부터 재정의된 함수이며, 키보드에 키가 눌러질때 호출되는 함수입니다.
   def keyPressEvent(self, e):        
        self.map.keyDown(e.key())

이 함수 또한 해당 키보드 이벤트가 발생 시 CMap의 keyDown() 함수를 호출해 해당키에 맞는 행동을 CMap이 수행하도록 유도하고 있습니다.

closeEvent() 함수 역시 마찬가지로 QWidget클래스의 재정의 함수이며, 윈도우 창이 닫기기 전에 호출됩니다.
 def closeEvent(self, e):
        self.map.exitGame()    


CMap 클래스의 exitGame()함수를 호출해, CMap이 실행중인 쓰레드의 종료를 처리합니다.

사실, 이 예제는 코드의 가독성 향상과 쓰레드 동기화 공부(Lock)를 돕기위해 적군생성, 적군이동, 총알이동 3개 파트를 멀티 쓰레드(Multi-Thread)로 구성하였습니다.

하지만 파이썬에서 멀티쓰레드는 GIL(Global Interpreter Lock) 때문에 코드의 수행속도를 오히려 늦추는 요인이 됩니다.

오히려 싱글쓰레드(Single-Thread)로 적군생성, 적군이동, 총알이동을 모두 구현하는 것이 더 속도가 빠를 것입니다.

이런 문제점을 알고 있다면, Multi-Processing을 공부할 시점이 된 것입니다.

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

pyinstller로 제작한 실행파일 링크 : 파이썬 슈팅게임


개발 환경

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

 

감사합니다.

댓글

이 블로그의 인기 게시물

Qt Designer 설치하기

C++ 예제 (소켓 서버, 이미지, 파일전송)