PyQt5 기반Tetris(테트리스) 게임앱

개요

이번 주제는 Python + PyQt5 로 만든 테스리스 게임입니다.

(키보드 화살표 ←, →, ↑(회전), ↓(빨리하강) 로 조작)


예전에 C++로 만들어 올려둔 게시물이 있는데, 구글에서 저작권 위반 신고가 들어와 게시물이 삭제(?)되어 현재는 게시글이 없어진 상태입니다.

제가 직접  짠 코드인데 이유는 정확히 모르지만 같이 넣어둔 게임 효과음이 원인이 아닐까 추측합니다.😓 

최근 심화반 학생들 중 테트리스 게임을 목표로 하는 학생이 있어 파이썬을 이용해 수업 참조 예제로 서둘러 만들어 보았습니다.  

테트리스는 난이도가 높은 예제이므로 게시물을 참조하여 포기하지 말고 끝까지 진행해 학습에 도움이 되었으면 하는 바램입니다.

버그가 있다면 댓글로 알려주시면 감사하겠습니다.

간단히 개발 과정을 요약해보면, 


  • 7가지 4x4 블럭을 생성, 블럭이 존재하는 부분은 값 (1) (2차원 Array)
    • 20x10의 배경맵(초기값 0)에 블럭을 겹치고 블럭이 존재하는 맵값을(1) 로 만들어 행 증가.
    • 행이 증가되면 블럭이 아래로 내려오고 이때, 저장해둔 이전 맵을 제거(값 1->0) 
    • 블럭 회전(방향키:↑) 은 회전된 블럭의 모양이 배경맵의 쌓인부분(2)과 겹치지 않으면 성공
      • 블럭의 다음행이 배경맵의 맨 아래(행 20) or 이미 쌓여진 부분(2)에 닿으면 값(2)변경
      • 다음 블럭을 새로 생성하고 다시 반복


      아래 그림을 참조바랍니다.

       

      개발환경 

      • Windows 11 Pro 64bit, Visual Studio 2022

      • Python 3.9 64bit, PyQt5 5.15.9

       

      소스코드

      모든 소스코드는 제가 직접 작성하였으며,

      코드 설명은 Google AI Gemini 에게 소스코드를 제공하고 설명을 부탁하였습니다.😁


      3개의 *.py 파일로 구성.

      • main.py (메인함수)

      • blocks.py (테트리스 블럭 및 추상화 클래스)

      • game.py (게임 진행 담당)

       

      Git Link : https://github.com/justdoit76/Tetris_Python

       

      main.py 소스코드

      소스코드의 메인 함수 입니다.

      from PyQt5.QtWidgets import QApplication, QWidget, QMessageBox
      from PyQt5.QtGui import QCloseEvent, QKeyEvent, QPaintEvent, QPainter
      from game import Tetris
      import sys
      
      from PyQt5.QtCore import Qt
      QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True)
      
      class Window(QWidget):
          
          def __init__(self):
              super().__init__()
              self.setWindowTitle('Ocean Coding School')
              self.setFixedSize(340,640)
              self.tetris = Tetris(self)        
              
          def paintEvent(self, e) -> None:
              qp = QPainter()
              qp.begin(self)
              self.tetris.draw(qp)
              qp.end()
              return super().paintEvent(e)        
              
          def keyPressEvent(self, e) -> None:
              self.tetris.keyDown(e.key())
              return super().keyPressEvent(e)        
          
          def closeEvent(self, e) -> None:      
              self.tetris.run = False
              return super().closeEvent(e)    
          
          def gameOver(self):
              result = QMessageBox.information(self, 'Game Over!', 'Retry(Y), Exit(N)',
                                               QMessageBox.Yes| QMessageBox.No)
              
              if result==QMessageBox.Yes:
                  del(self.tetris)
                  self.tetris = Tetris(self)
              else:
                  self.close()        
              
      if __name__ == '__main__':
          app = QApplication(sys.argv)
          w = Window()
          w.show()
          sys.exit(app.exec_())
      

      [__init__]

      게임 창 제목, 크기 설정

      Tetris 객체 생성

      게임 시작

      [paintEvent] 

      QPainter 객체 생성

      Tetris 객체의 draw 함수 호출하여 게임 화면 그리기

      [keyPressEvent]

      눌린 키에 따라 Tetris 객체의 keyDown 함수 호출

      [closeEvent]

      Tetris 객체의 run 플래그 False로 설정하여 게임 종료

      [gameOver]

      게임 종료 메시지 출력

      사용자 선택에 따라 게임 재시작 또는 종료


      blocks.py 소스코드

      테트리스 블럭들의 공통 요소를 추상화 클래스(Abstract Base Class)로 하고 각 블럭들은 상속받는 형태로 구현.

      from abc import ABC, abstractmethod
      from enum import Enum
      
      class BType(Enum):
          O = 0
          I = 1
          S = 2
          Z = 3
          L = 4
          J = 5
          T = 6
      
      class Block(ABC):
      
          Size = 4
              
          @abstractmethod
          def __init__(self, type):
              super().__init__()        
              self.type = type
              self.arr = []
              self.idx = 0        
          
          def rotate_r(self):
              size = len(self.arr)
              
              if self.idx>=size-1:
                  self.idx = 0
              else:
                  self.idx += 1        
          
          def rotate_l(self):
              size = len(self.arr)
              
              if self.idx<=0:
                  self.idx = size-1
              else:
                  self.idx -= 1     
      
          def findTail(self):
              block = self.arr[self.idx]
              U = self.findUpperTail(block)
              D = self.findLowerTail(block)
              L = self.findLeftTail(block)
              R = self.findRightTail(block)
              
              return U, D, L, R
              
          def findUpperTail(self, block):        
              for r in range(Block.Size):
                  for c in range(Block.Size):                            
                      if block[r][c]:
                          return r
                      
          def findLowerTail(self, block):        
              for r in range(Block.Size-1, -1, -1):
                  for c in range(Block.Size):                            
                      if block[r][c]:
                          return r
                      
          def findLeftTail(self, block):        
              for c in range(Block.Size):
                  for r in range(Block.Size):                            
                      if block[r][c]:
                          return c
                      
          def findRightTail(self, block):        
              for c in range(Block.Size-1, -1, -1):
                  for r in range(Block.Size):                            
                      if block[r][c]:
                          return c
              
          def print(self):        
              print('-'*Block.Size)            
              for r in range(Block.Size):
                  for c in range(Block.Size):
                      if self.arr[self.idx][r][c]:
                          print('■', end='')
                      else:
                          print('□', end='')
                  print()
              print('-'*Block.Size)
      
      class BO(Block):    
          def __init__(self):
              super().__init__(BType.O)
              self.color = (255,255,0,255)
              # 0
              temp = [ [False, False, False, False] for _ in range(Block.Size) ]
              for r in range(1, 3):
                  for c in range(1, 3):
                      temp[r][c] = True
              self.arr.append(temp)
      
      class BI(Block):    
          def __init__(self):
              super().__init__(BType.I)
              self.color = (115,251,253,255)
              # 0
              temp =  [ [False, False, False, False] for _ in range(Block.Size) ]        
              for c in range(Block.Size):            
                  temp[1][c] = True
              self.arr.append(temp)
              
              # 1
              temp =  [ [False, False, False, False] for _ in range(Block.Size) ]
              for r in range(Block.Size):
                  temp[r][2] = True
              self.arr.append(temp)            
                  
      class BS(Block):    
          def __init__(self):
              super().__init__(BType.S)    
              self.color = (0,255,0,255)
              # 0
              temp =  [ [False, False, False, False] for _ in range(Block.Size) ]
              temp[1][2] = True
              temp[1][3] = True
              temp[2][1] = True
              temp[2][2] = True
              self.arr.append(temp)
              
              # 1
              temp =  [ [False, False, False, False] for _ in range(Block.Size) ]
              temp[0][2] = True
              temp[1][2] = True
              temp[1][3] = True
              temp[2][3] = True
              self.arr.append(temp)
              
      class BZ(Block):    
          def __init__(self):
              super().__init__(BType.Z)     
              self.color = (0,255,0,255)
              # 0
              temp =  [ [False, False, False, False] for _ in range(Block.Size) ]
              temp[2][2] = True
              temp[2][3] = True
              temp[1][1] = True
              temp[1][2] = True
              self.arr.append(temp)
              
              # 1
              temp =  [ [False, False, False, False] for _ in range(Block.Size) ]
              temp[0][3] = True
              temp[1][2] = True
              temp[1][3] = True
              temp[2][2] = True
              self.arr.append(temp)       
              
      class BL(Block):    
          def __init__(self):        
              super().__init__(BType.L)        
              self.color = (255,168,76,255)
              # 0        
              temp =  [ [False, False, False, False] for _ in range(Block.Size) ]        
              temp[1][1] = True
              temp[1][2] = True
              temp[1][3] = True
              temp[2][1] = True
              self.arr.append(temp)
              
              # 1        
              temp =  [ [False, False, False, False] for _ in range(Block.Size) ]        
              temp[0][2] = True
              temp[1][2] = True
              temp[2][2] = True
              temp[2][3] = True
              self.arr.append(temp)
              
              # 2        
              temp =  [ [False, False, False, False] for _ in range(Block.Size) ]        
              temp[0][3] = True
              temp[1][1] = True
              temp[1][2] = True
              temp[1][3] = True
              self.arr.append(temp)
              
              # 4        
              temp =  [ [False, False, False, False] for _ in range(Block.Size) ]        
              temp[0][1] = True
              temp[0][2] = True
              temp[1][2] = True
              temp[2][2] = True
              self.arr.append(temp)
              
      class BJ(Block):    
          def __init__(self):
              super().__init__(BType.J)
              self.color = (0,0,255,255)
              # 0        
              temp =  [ [False, False, False, False] for _ in range(Block.Size) ]        
              temp[1][1] = True
              temp[1][2] = True
              temp[1][3] = True
              temp[2][3] = True
              self.arr.append(temp)
              
              # 1        
              temp =  [ [False, False, False, False] for _ in range(Block.Size) ]        
              temp[0][2] = True
              temp[0][3] = True
              temp[1][2] = True
              temp[2][2] = True
              self.arr.append(temp)
              
              # 2        
              temp =  [ [False, False, False, False] for _ in range(Block.Size) ]        
              temp[0][1] = True
              temp[1][1] = True
              temp[1][2] = True
              temp[1][3] = True
              self.arr.append(temp)
              
              # 4        
              temp =  [ [False, False, False, False] for _ in range(Block.Size) ]        
              temp[0][2] = True
              temp[1][2] = True
              temp[2][1] = True
              temp[2][2] = True
              self.arr.append(temp)
              
      class BT(Block):    
          def __init__(self):
              super().__init__(BType.T)
              self.color = (255,0,255,255)
              # 0        
              temp =  [ [False, False, False, False] for _ in range(Block.Size) ]        
              temp[1][1] = True
              temp[1][2] = True
              temp[1][3] = True
              temp[2][2] = True
              self.arr.append(temp)
              
              # 1        
              temp =  [ [False, False, False, False] for _ in range(Block.Size) ]        
              temp[0][2] = True
              temp[1][2] = True
              temp[1][3] = True
              temp[2][2] = True
              self.arr.append(temp)
              
              # 2        
              temp =  [ [False, False, False, False] for _ in range(Block.Size) ]        
              temp[0][2] = True
              temp[1][1] = True
              temp[1][2] = True
              temp[1][3] = True
              self.arr.append(temp)
              
              # 4        
              temp =  [ [False, False, False, False] for _ in range(Block.Size) ]        
              temp[0][2] = True
              temp[1][1] = True
              temp[1][2] = True
              temp[2][2] = True
              self.arr.append(temp)      
      

      [__init__]

      블록 타입, 초기 모양, 회전 인덱스 설정

      [rotate_r]

      블록 시계 방향 회전

      [rotate_l]

      블록 반시계 방향 회전

      [findTail]

      블록의 상하좌우 가장 끝 셀 찾기

      [findUpperTail]

      블록 상단 가장 끝 셀 찾기

      [findLowerTail]

      블록 하단 가장 끝 셀 찾기

      [findLeftTail]

      블록 왼쪽 가장 끝 셀 찾기

      [findRightTail]

      블록 오른쪽 가장 끝 셀 찾기

      [print]

      블록 모양 출력

       

      game.py 소스코드

      실제 게임 진행을 담당하는 클래스, 별도의 실행흐름(Thread)을 생성한 후 아래와 같이 게임을 진행.

      1. Initialization of  Game Elements

      2. Generating Thread

      3. Moving and Rotating Blocks

      4. Block Stacking and Line Clearing

      이 후, 3, 4번을 쓰레드에서 반복

      이해하기 쉽도록 블럭들의 상태는 "0:없음, 1:이동중, 2:쌓기" 로 표시해 두었습니다.

      from PyQt5.QtCore import Qt, QRectF, QObject, pyqtSignal
      from PyQt5.QtGui import QBrush, QColor
      from blocks import BO,BI,BS,BZ,BL,BJ,BT, BType
      from threading import Thread, Lock
      from random import randint
      import time
      
      class Tetris(QObject):
          
          Row = 20
          Col = 10
          Blocks = (BO, BI, BS, BZ, BL, BJ, BT)
          update_signal = pyqtSignal()
          gameover_signal = pyqtSignal()
          
          def __init__(self, w):
              super().__init__()
              self.parent = w
              self.rect = w.rect()
              
              self.inrect = QRectF(self.rect)
              gap = 20
              self.inrect.adjust(gap, gap, -gap, -gap)        
      
              self.size = self.inrect.height() / Tetris.Row
              
              # create block
              self.initBlock()
              
              # create map
              self.initMap()
              
              # signal
              self.update_signal.connect(w.update)
              self.gameover_signal.connect(w.gameOver)
              
              # thread
              self.cs = Lock()
              self.t = Thread(target = self.threadFunc)
              self.run = True
              self.t.start()   
              
          def initBlock(self):
               # block        
              n = randint(BType.O.value, BType.T.value)
              print('Block Type:', n)
              self.block = Tetris.Blocks[n]()
              
              # standard row, col        
              self.cy = -1
              self.cx = Tetris.Col//2-self.block.Size//2
              
          def initMap(self):
              # color map
              self.cmaps = [[(0,0,0,0) for _ in range(Tetris.Col)] for _ in range(Tetris.Row)] 
      
              # logical map (0:None, 1:Move block, 2:Stacked block)
              self.before = []
              self.maps = [[0 for _ in range(Tetris.Col)] for _ in range(Tetris.Row)] 
              
              # displayed map        
              self.rects = []
              x = self.inrect.left()        
              y = self.inrect.top()
              for r in range(Tetris.Row):
                  temp = []
                  for c in range(Tetris.Col):
                      dx = x+c*self.size
                      dy = y+r*self.size
                      rect = QRectF(dx, dy, self.size, self.size)
                      temp.append(rect)
                  self.rects.append(temp)
              
          def draw(self, qp):
              self.drawBackground(qp)
              self.drawBlock(qp)      
              
          def drawBackground(self, qp):
              x = self.inrect.left()        
              y = self.inrect.top()
              
              x2 = self.inrect.right()
              y2 = self.inrect.top()
              
              x3 = self.inrect.left()
              y3 = self.inrect.bottom()
              
              for i in range(Tetris.Row+1):
                  qp.drawLine(x, y+i*self.size, x2, y2+i*self.size)
                  if i<Tetris.Col+1:
                      qp.drawLine(x+i*self.size, y, x3+i*self.size, y3)
          
          def drawBlock(self, qp):                   
              for r in range(Tetris.Row):
                  for c in range(Tetris.Col):
                      if self.maps[r][c]!=0:
                          if self.maps[r][c]==1:
                              R, G, B, A = self.block.color
                              b = QBrush(QColor(R,G,B,A))
                              qp.setBrush(b)
                          else:
                              R, G, B, A = self.cmaps[r][c]
                              b = QBrush(QColor(R,G,B,A))                        
                              qp.setBrush(b)
                      else:
                          qp.setBrush(Qt.NoBrush)                    
      
                      qp.drawRect(self.rects[r][c])
                      qp.drawText(self.rects[r][c], Qt.AlignCenter, f'{self.maps[r][c]}')
      
          def keyDown(self, key):
              self.cs.acquire()
              
              # find tail of blocks
              U, D, L, R = self.block.findTail()        
      
              if key==Qt.Key_Left:
                  if self.cx>0-L and self.isOverlapped(self.cx-1, self.cy)==False:
                      self.cx-=1
                      self.blockUpdate()                
              elif key==Qt.Key_Right:
                  if self.cx<Tetris.Col-1-R and self.isOverlapped(self.cx+1, self.cy)==False:
                      self.cx+=1
                      self.blockUpdate()                
              elif key==Qt.Key_Up:
                  self.block.rotate_r()
                  U, D, L, R = self.block.findTail()
                  # block has rotated off the screen or overlapped other block
                  if (self.cx<0-L or self.cx>Tetris.Col-1-R) or self.isOverlapped(self.cx, self.cy):
                      self.block.rotate_l()
                  self.blockUpdate()   
                  self.block.print()
              elif key==Qt.Key_Down:            
                  if self.cy-D<Tetris.Row-2:
                      self.cy+=1
                      self.blockUpdate()
                      
              self.cs.release()
              
          def isOverlapped(self, cx, cy):
              U, D, L, R = self.block.findTail()
              bl = self.block.arr[self.block.idx]
              for r in range(U, D+1):
                  for c in range(L, R+1):
                      if bl[r][c] and self.maps[cy-r-1][c+cx]==2:
                          return True
              return False
              
          def blockUpdate(self):
              bl = self.block.arr[self.block.idx]
              size = self.block.Size        
              
              # delete before blocks
              for r, c in self.before:
                  self.maps[r][c] = 0
              self.before.clear()
              
              # set current blocks
              for r in range(size):            
                  for c in range(size):
                      if bl[size-1-r][c]:
                          if self.cy-r>=0:
                              self.maps[self.cy-r][c+self.cx]=1
                              # remember current blocks
                              self.before.append( (self.cy-r, c+self.cx) )
                     
              # stack or not
              if not self.isMoveDown():
                  if self.cy<=1:
                      return False
                  self.stackBlock()
                  self.removeBlock()
                  self.initBlock()
              
              self.update_signal.emit()        
              return True
                              
          def isMoveDown(self):
              bl = self.block.arr[self.block.idx]
              size = self.block.Size
              
              for r in range(size):            
                  for c in range(size):
                      if bl[size-1-r][c]:                   
                          # bottom of map
                          if self.cy - r + 1 > Tetris.Row-1:
                              self.before.clear()
                              return False                    
                          elif self.cy-r >= 0:
                              # found stacked block
                              if self.maps[self.cy-r+1][c+self.cx]==2:                            
                                  self.before.clear() 
                                  return False
              return True
          
          def stackBlock(self):
              bl = self.block.arr[self.block.idx]
              size = self.block.Size  
              color = self.block.color
              
              for r in range(size):            
                  for c in range(size):
                      if bl[size-1-r][c]:                
                          self.maps[self.cy-r][c+self.cx]=2
                          self.cmaps[self.cy-r][c+self.cx]=color
                          
          def removeBlock(self):        
              # find remove line
              lines = []        
              for r in range(Tetris.Row):
                  cnt = 0
                  for c in range(Tetris.Col):
                      if self.maps[r][c]==2:
                          cnt+=1
                      else:
                          break
      
                  if cnt == Tetris.Col:
                      lines.append(r)
                      
              if lines:            
                  # remove line        
                  for r in lines:
                      for c in range(Tetris.Col):
                          self.maps[r][c] = 0
                      
                      self.update_signal.emit()
                      time.sleep(0.2)               
                      
                      # fall blocks                
                      for rr in range(r-1, -1, -1):
                          for cc in range(Tetris.Col):
                              if self.maps[rr][cc]==2:
                                  self.maps[rr+1][cc] = 2
                                  self.maps[rr][cc] = 0
                                  
                                  self.update_signal.emit()
                                  time.sleep(0.1)
                      time.sleep(0.2)               
                      
      
          def threadFunc(self):
              while self.run:                            
                  self.cs.acquire()
                  self.cy+=1       
                  if not self.blockUpdate():
                      self.gameover_signal.emit()
                      break
                  #self.cy+=1       
                  self.cs.release()
                  print(self.cy, self.cx)
                  time.sleep(0.5)            
              print('thread finished...')
      

      [__init__]

      self.parent = w: 게임 화면 위젯을 저장

      게임 화면 영역 계산 (self.rect)

      내부 게임 영역 계산 (self.inrect)

      블록 크기 계산 (self.size)

      초기 블록 생성 (self.initBlock())

      맵 초기화 (self.initMap())

      시그널 연결 (게임 화면 업데이트, 게임 오버)

      게임 쓰레드 생성 및 시작 (self.t.start())

      [initBlock]

      랜덤하게 블록 타입 선택

      선택된 블록 클래스 생성

      블록 초기 위치 설정

      [initMap]

      맵을 나타내는 2차원 리스트 초기화 (빈 공간, 이동 가능, 쌓인 블록)

      화면 표시를 위한 QRectF 객체들의 리스트 생성

      [draw]

      게임 배경 그리기

      블록 그리기

      [drawBackground]

      가로 및 세로 선으로 게임 맵 그리기

      [drawBlock]

      맵 데이터를 확인하여 이동 가능한 블록, 쌓인 블록 구분하여 채우기

      블록 모양 및 맵 데이터 그리기

      [keyDown]

      눌린 키에 따라 블록 이동, 회전 처리

      락 (아래로 이동) 처리 시 블록 쌓임 및 라인 삭제 체크

      [isOverlapped]

      새로운 위치에서 블록과 기존 맵이 겹치는지 검사

      [blockUpdate]

      블록 이동 및 맵 업데이트

      이전 위치 블록 삭제

      새로운 위치 블록 표시

      쌓임 여부 확인

      쌓이면 블록 쌓기

      라인 삭제 필요 시 처리

      게임 화면 업데이트 시그널 발생

      [isMoveDown]

      블록이 아래로 이동 가능한지 검사

      [stackBlock]

      현재 위치 블록을 쌓기

      [removeBlock]

      완성된 라인 찾기

      라인 삭제 및 점수 계산 (아직 구현되지 않음)

      라인 삭제 후 블록 떨어뜨리기 효과

      [threadFunc]

      게임 쓰레드에서 반복적으로 수행

      블록을 한 칸 아래로 이동시키고 업데이트

      블록 이동 실패 시 게임 오버 시그널 발생

      일정 시간 간격으로 게임 진행

       


      이상으로 모든 설명을 마칩니다.

      마지막으로 왜 PyGame 을 사용하지 않고 PyQt를 사용했는지 물어보는 분들이 가끔씩 계십니다.

      그 이유는 파이썬에는 수많은 모듈이 존재하고 그 모듈의 사용법에 익숙해 지는데 많은 시간과 노력이 필요합니다.

      PyGame은 오직 게임에만 사용할 수 있다면, PyQt는 좀 더 넓은 사용처 (일반 앱, GUI, 다양한 플랫폼) 에 적용할 수 있으므로 활용도가 더 높다고 생각합니다.

      감사합니다.

      댓글

      이 블로그의 인기 게시물

      Qt Designer 설치하기

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