pyqt5制作俄罗斯方块小游戏-----源码解析,二、绘制主窗口将主窗


一、前言

最近学习pyqt5中文教程时,最后一个例子制作了一个俄罗斯方块小游戏,由于解释的不是很清楚,所以源码有点看不懂,查找网上资料后,大概弄懂了源码的原理。

二、绘制主窗口

将主窗口居中,且设置了一个状态栏来显示三种信息:消除的行数,游戏暂停状态或者游戏结束状态。

class Tetris(QMainWindow):

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

        self.initUI()


    def initUI(self):
        '''initiates application UI'''
        # 创建了一个Board类的实例,并设置为应用的中心组件
        self.tboard = Board(self)
        self.setCentralWidget(self.tboard)
        # 创建一个statusbar来显示三种信息:消除的行数,游戏暂停状态或者游戏结束状态
        # msg2Statusbar是一个自定义的信号,用在(和)Board类(交互),showMessage()方法是一个内建的,用来在statusbar上显示信息的方法。
        self.statusbar = self.statusBar()
        self.tboard.msg2Statusbar[str].connect(self.statusbar.showMessage)

        self.tboard.start() # 初始化游戏

        self.resize(213, 426)   #  设置窗口大小
        # self.setGeometry(300, 300, 500, 300)
        self.center()   # 窗口居中
        self.setWindowTitle('Tetris')   # 标题
        self.show() # 展示窗口


    def center(self):
        '''centers the window on the screen'''
        # screenGeometry()函数提供有关可用屏幕几何的信息
        screen = QDesktopWidget().screenGeometry()
        # 获取窗口坐标系
        size = self.geometry()
        # 将窗口放到中间
        self.move((screen.width()-size.width())/2,
            (screen.height()-size.height())/2)

其中Board类是我们后面要创建的类,主要定义了游戏的运行逻辑。
通过QDesktopWidget().screenGeometry(),获取了电脑屏幕的大小,
然后通过self.geometry()获取了主窗口的大小,将主窗口放到屏幕中央。

三、绘制俄罗斯方块的形状

以某行某列为原点,绘制俄罗斯方块的形状。
俄罗斯方块有7种基本形状,如图

每个方块形状都有四个小方块,图中的坐标显示的是小方块左上角的坐标。
定义一个Tetrominoe类,保存所有方块的形状(其实相当于后面coordsTable数组里的index)。

# Tetrominoe类保存了所有方块的形状。我们还定义了一个NoShape的空形状。
class Tetrominoe(object):
    # 和Shape类里的coordsTable数组一一对应
    NoShape = 0
    ZShape = 1
    SShape = 2
    LineShape = 3
    TShape = 4
    SquareShape = 5
    LShape = 6
    MirroredLShape = 7

定义Shape类,保存类方块内部的信息。

# Shape类保存类方块内部的信息。
class Shape(object):
    # coordsTable元组保存了所有的方块形状的组成。是一个构成方块的坐标模版。
    coordsTable = (
        ((0, 0),     (0, 0),     (0, 0),     (0, 0)),   # 空方块
        ((0, -1),    (0, 0),     (-1, 0),    (-1, 1)),
        ((0, -1),    (0, 0),     (1, 0),     (1, 1)),
        ((0, -1),    (0, 0),     (0, 1),     (0, 2)),
        ((-1, 0),    (0, 0),     (1, 0),     (0, 1)),
        ((0, 0),     (1, 0),     (0, 1),     (1, 1)),
        ((-1, -1),   (0, -1),    (0, 0),     (0, 1)),
        ((1, -1),    (0, -1),    (0, 0),     (0, 1))
    )

    def __init__(self):
        # 下面创建了一个新的空坐标数组,这个数组将用来保存方块的坐标。
        self.coords = [[0,0] for i in range(4)]     # 4x4的二维数组,每个元素代表方块的左上角坐标
        self.pieceShape = Tetrominoe.NoShape    # 方块形状,初始形状为空白

        self.setShape(Tetrominoe.NoShape)

    # 返回当前方块形状
    def shape(self):
        '''returns shape'''

        return self.pieceShape

    # 设置方块形状
    def setShape(self, shape):  # 初始shape为0
        '''sets a shape'''

        table = Shape.coordsTable[shape]    # 从形状列表里取出其中一个方块的形状,为一个4x2的数组

        for i in range(4):
            for j in range(2):
                self.coords[i][j] = table[i][j] # 赋给要使用的方块元素

        self.pieceShape = shape # 再次获取形状(index)

    # 设置一个随机的方块形状
    def setRandomShape(self):
        '''chooses a random shape'''

        self.setShape(random.randint(1, 7))

    # 小方块的x坐标,index代表第几个方块
    def x(self, index):
        '''returns x coordinate'''

        return self.coords[index][0]

    # 小方块的y坐标
    def y(self, index):
        '''returns y coordinate'''

        return self.coords[index][1]

    # 设置小方块的x坐标
    def setX(self, index, x):
        '''sets x coordinate'''

        self.coords[index][0] = x

    # 设置小方块的y坐标
    def setY(self, index, y):
        '''sets y coordinate'''

        self.coords[index][1] = y

    # 找出方块形状中位于最左边的方块的x坐标
    def minX(self):
        '''returns min x value'''

        m = self.coords[0][0]
        for i in range(4):
            m = min(m, self.coords[i][0])

        return m

    # 找出方块形状中位于最右边的方块的x坐标
    def maxX(self):
        '''returns max x value'''

        m = self.coords[0][0]
        for i in range(4):
            m = max(m, self.coords[i][0])

        return m

    # 找出方块形状中位于最左边的方块的y坐标
    def minY(self):
        '''returns min y value'''

        m = self.coords[0][1]
        for i in range(4):
            m = min(m, self.coords[i][1])

        return m

    # 找出方块形状中位于最右边的方块的y坐标
    def maxY(self):
        '''returns max y value'''

        m = self.coords[0][1]
        for i in range(4):
            m = max(m, self.coords[i][1])

        return m

注意,不同人对方块坐标的定义不同,但基本原理一致。

四、旋转方块

旋转方块,其实相当于将坐标轴旋转,以一个方块形状为例,向左旋转如图

坐标轴变化(x,y) -> (y,-x)。

    # rotateLeft()方法向右旋转一个方块。正方形的方块就没必要旋转,就直接返回了。
    # 其他的是返回一个新的,能表示这个形状旋转了的坐标。
    def rotateLeft(self):
        '''rotates shape to the left'''
        # 正方形没有必要旋转
        if self.pieceShape == Tetrominoe.SquareShape:
            return self
        # 获取当前的方块形状
        result = Shape()
        result.pieceShape = self.pieceShape
        # 向左旋转,相当将坐标轴向左旋转了,和原来的坐标轴想比 (x,y) -> (y,-x)
        for i in range(4):  # i代表第几个小方块
            result.setX(i, self.y(i))   # 设置第i个方块的x坐标,
            result.setY(i, -self.x(i))  # 设置第i个方块的x坐标

        return result

这段代码放在Shape类里。
同理,向右旋转,坐标轴变化(x,y) -> (-y,x)。

    # 向右旋转,同理,(x,y) -> (-y,x)
    def rotateRight(self):
        '''rotates shape to the right'''

        if self.pieceShape == Tetrominoe.SquareShape:
            return self

        result = Shape()
        result.pieceShape = self.pieceShape

        for i in range(4):
            result.setX(i, -self.y(i))
            result.setY(i, self.x(i))

        return result

代码同样放在Shape类里。

五、游戏运行逻辑

这块是最难理解也是最重要的一块。

(1)初始化变量

定义一个Board类来描述游戏的运行逻辑。

class Board(QFrame):
    # 创建了一个自定义信号msg2Statusbar,当我们想往statusbar里显示信息的时候,发出这个信号就行了。
    msg2Statusbar = pyqtSignal(str)
    # 这些是Board类的变量。BoardWidth和BoardHeight分别是board的宽度和高度。Speed是游戏的速度,每300ms出现一个新的方块
    BoardWidth = 10 # 指界面宽度可以容纳10个小方块
    BoardHeight = 22    # 指界面高度可以容纳22个小方块
    Speed = 300

    def __init__(self, parent):
        super().__init__(parent)

        self.initBoard()


    def initBoard(self):
        '''initiates board'''

        self.timer = QBasicTimer()  # 定义了一个定时器
        self.isWaitingAfterLine = False # self.isWaitingAfterLine表示是否在等待消除行

        self.curX = 0   # 目前x坐标
        self.curY = 0   # 目前y坐标
        self.numLinesRemoved = 0    # 表示消除的行数,也就是分数
        self.board = [] # 存储每个方块位置的形状,默认应该为0,下标代表方块坐标x*y

        self.setFocusPolicy(Qt.StrongFocus) # 设置焦点,使用tab键和鼠标左键都可以获取焦点
        self.isStarted = False  # 表示游戏是否在运行状态
        self.isPaused = False   # 表示游戏是否在暂停状态
        self.clearBoard()   # 清空界面的全部方块
msg2Statusbar = pyqtSignal(str)

这段代码自定义了一个信号。

self.timer = QBasicTimer()

这段代码定义了一个定时器。

self.setFocusPolicy(Qt.StrongFocus)

这段代码设置了焦点,TabFocus 只能使用Tab键才能获取焦点,ClickFocus 只能使用鼠标点击才能获取焦点,StrongFocus 上面两种都行,NoFocus 上面两种都不行。
所谓焦点,其实就是你得鼠标光标移动到了该点。

(2)清空界面

初始化变量时,调用 self.clearBoard()清空了界面。

    # clearBoard()方法通过Tetrominoe.NoShape清空broad
    def clearBoard(self):
        '''clears shapes from the board'''
        # 将界面每个小方块都设置为空,存储到self.board中,下标表示第几个方块,(x*y)
        for i in range(Board.BoardHeight * Board.BoardWidth):
            self.board.append(Tetrominoe.NoShape)

Board.BoardHeightBoard.BoardWidth代表界面宽度和高度能够容纳多少个小方块,Board.BoardHeight * Board.BoardWidth表示方块的顺序,相当于self.board的下标。

(3)启动游戏

接下来是开始游戏的方法。

# 开始游戏
    def start(self):
        '''starts game'''
        # 如果游戏处于暂停状态,直接返回
        if self.isPaused:
            return
        
        self.isStarted = True   # 将开始状态设置为True
        self.isWaitingAfterLine = False
        self.numLinesRemoved = 0    # 将分数设置为0
        self.clearBoard()   # 清空界面全部的方块
        # 状态栏显示当前有多少分
        self.msg2Statusbar.emit(str(self.numLinesRemoved))

        self.newPiece() # 创建一个新的方块
        self.timer.start(Board.Speed, self) # 开始计时,每过300ms刷新一次当前的界面

(4)新建方块

这里调用了一个函数self.newPiece(),新建了一个方块。

  # newPiece()方法是用来创建形状随机的方块。如果随机的方块不能正确的出现在预设的位置,游戏结束。
    def newPiece(self):
        '''creates a new shape'''

        self.curPiece = Shape() # 创建了一个Shape对象
        self.curPiece.setRandomShape()  # 设置了一个随机的形状
        self.curX = Board.BoardWidth // 2 + 1   # 以界面中心为起点
        self.curY = Board.BoardHeight - 1 + self.curPiece.minY() # 从这里看应该是预留了一行的高度,但不知道作用是什么
        # 判断是否还有空位,如果没有
        if not self.tryMove(self.curPiece, self.curX, self.curY):
            # 将当前形状设置为空
            self.curPiece.setShape(Tetrominoe.NoShape)
            self.timer.stop()   # 停止计时
            self.isStarted = False  # 将开始状态设置为False
            self.msg2Statusbar.emit("Game over") # 状态栏显示游戏结束

调用了tryMove()函数。

    # tryMove()是尝试移动方块的方法。
    # 如果方块已经到达board的边缘或者遇到了其他方块,就返回False。否则就把方块下落到想要的位置
    def tryMove(self, newPiece, newX, newY):
        '''tries to move a shape'''

        for i in range(4):
            # newPiece是一个Shape对象,newX,newY相当于坐标原点(相对于方块而言)
            x = newX + newPiece.x(i)    # 得到每个小方块在界面上的坐标
            y = newY - newPiece.y(i)
            # 超出边界则返回False
            if x < 0 or x >= Board.BoardWidth or y < 0 or y >= Board.BoardHeight:
                return False
            # 如果方块位置不为0,说明已经用过了,不允许使用,返回False
            if self.shapeAt(x, y) != Tetrominoe.NoShape:
                return False

        self.curPiece = newPiece    # 更新当前的方块形状
        self.curX = newX    # 更新当前的坐标
        self.curY = newY
        self.update()   # 更新窗口,同时调用paintEvent()函数

        return True

注意,y坐标要减去小方块的y坐标,y = newY - newPiece.y(i),因为在界面上的坐标轴是这样的
而小方块的坐标是这样的
其实坐标轴的基本单位是一个小方块,当做方块来处理就可以了
这里调用了shapeAt()方法,传入了当前小方块的坐标。

    # shapeAt()决定了board里方块的的种类。
    def shapeAt(self, x, y):
        '''determines shape at the board position'''
        # 返回的是(x,y)坐标方块在self.board中的值
        return self.board[(y * Board.BoardWidth) + x]

(y * Board.BoardWidth) + x计算出了方块的位置,至于怎么计算的这里就不说了,参照二维数组。
self.update()函数更新了当前的窗口,且会调用paintEvent()函数。

(5)绘制方块

   # 渲染是在paintEvent()方法里发生的QPainter负责PyQt5里所有低级绘画操作。
    def paintEvent(self, event):
        '''paints all shapes of the game'''

        painter = QPainter(self)    # 新建了一个QPainter对象
        rect = self.contentsRect()  # 获取内容区域
        # self.squareHeight()获取的是小方块的高度,不是很理解,猜测是方块出现后去获取方块的高度
        boardTop = rect.bottom() - Board.BoardHeight * self.squareHeight()  # 获取board中除去方块后多出来的空间
        # 渲染游戏分为两步。第一步是先画出所有已经落在最下面的的图,这些保存在self.board里。
        # 可以使用shapeAt()查看这个这个变量。
        for i in range(Board.BoardHeight):
            for j in range(Board.BoardWidth):
                # 返回存储在self.board里面的形状
                shape = self.shapeAt(j, Board.BoardHeight - i - 1)
                # 如果形状不是空,绘制方块
                if shape != Tetrominoe.NoShape:
                    # 绘制方块,rect.left()表示Board的左边距
                    self.drawSquare(painter,
                        rect.left() + j * self.squareWidth(),
                        boardTop + i * self.squareHeight(), shape)
        # 第二步是画出正在下落的方块
        # 获取目前方块的形状,不能为空
        if self.curPiece.shape() != Tetrominoe.NoShape:

            for i in range(4):
                # 计算在Board上的坐标,作为方块坐标原点(单位是小方块)
                x = self.curX + self.curPiece.x(i)  
                y = self.curY - self.curPiece.y(i)
                # 绘制方块
                self.drawSquare(painter, rect.left() + x * self.squareWidth(),
                    boardTop + (Board.BoardHeight - y - 1) * self.squareHeight(),
                    self.curPiece.shape())

分两步画图,第一步画已经存在底部的方块,第二步画正在下落的方块。
调用了self.drawSquare()来绘制小方块。

    def drawSquare(self, painter, x, y, shape):
        '''draws a square of a shape'''

        colorTable = [0x000000, 0xCC6666, 0x66CC66, 0x6666CC,
                      0xCCCC66, 0xCC66CC, 0x66CCCC, 0xDAAA00]
        # 为每种形状的方块设置不同的颜色
        color = QColor(colorTable[shape])
        # 参数分别为x,y,w,h,color,填充了颜色
        painter.fillRect(x + 1, y + 1, self.squareWidth() - 2,
            self.squareHeight() - 2, color)

        painter.setPen(color.lighter())
        # 画线,从起始坐标到终点坐标,-1是为了留一点空格,看起来更有立体感
        painter.drawLine(x, y + self.squareHeight() - 1, x, y) # 左边那条线
        painter.drawLine(x, y, x + self.squareWidth() - 1, y)   # 上边那条线
        # 换了画笔的样式,同样是为了让图案看起来更有立体感
        painter.setPen(color.darker())
        painter.drawLine(x + 1, y + self.squareHeight() - 1,
            x + self.squareWidth() - 1, y + self.squareHeight() - 1)    # 下边那条线
        painter.drawLine(x + self.squareWidth() - 1,
            y + self.squareHeight() - 1, x + self.squareWidth() - 1, y + 1) # 右边那条线

调用squareWidth()和squareHeight()方法返回小方块的宽度和高度。

    # board的大小可以动态的改变。所以方格的大小也应该随之变化。squareWidth()计算并返回每个块应该占用多少像素--也即Board.BoardWidth。
    def squareWidth(self):
        '''returns the width of one square'''

        return self.contentsRect().width() // Board.BoardWidth


    def squareHeight(self):
        return self.contentsRect().height() // Board.BoardHeight

(6)方块移动和消除

a. 消除方块

    def pieceDropped(self):
        '''after dropping shape, remove full lines and create new shape'''
        # 将方块的形状添加到self.board中,非0代表该处有方块
        for i in range(4):
            # 获取每个小方块的坐标
            x = self.curX + self.curPiece.x(i)
            y = self.curY - self.curPiece.y(i)
            self.setShapeAt(x, y, self.curPiece.shape())
        # 移除满行的方块
        self.removeFullLines()
        # self.isWaitingAfterLine表示是否在等待消除行,如果不在等待就新建一个方块
        if not self.isWaitingAfterLine:
            self.newPiece()

调用self.setShapeAt()函数将当前落到底部的方块添加到self.board数组中去。只要非0都代表该处有方块。

    def setShapeAt(self, x, y, shape):
        '''sets a shape at the board'''
        # 设置方块的形状,放入self.board中
        self.board[(y * Board.BoardWidth) + x] = shape

调用self.removeFullLines()函数来消除方块。

    # 如果方块碰到了底部,就调用removeFullLines()方法,找到所有能消除的行消除它们。
    # 消除的具体动作就是把符合条件的行消除掉之后,再把它上面的行下降一行。
    # 注意移除满行的动作是倒着来的,因为我们是按照重力来表现游戏的,如果不这样就有可能出现有些方块浮在空中的现象
    def removeFullLines(self):
        '''removes all full lines from the board'''

        numFullLines = 0    # 记录消除的行数
        rowsToRemove = []   # 要消除的行列表

        for i in range(Board.BoardHeight):  # 遍历每一行

            n = 0
            for j in range(Board.BoardWidth): # 遍历整行的方块
                # 如果self.board里面的值不为空,计数
                if not self.shapeAt(j, i) == Tetrominoe.NoShape:
                    n = n + 1
            # 如果整行都有方块,将要消除的行添加进数组中
            if n == Board.BoardWidth:   # 原文是 n == 10,但我觉得该成n == Board.BoardWidth会更严谨一点
                rowsToRemove.append(i)
        # 因为是从上往下遍历,所以要倒过来消除,否则会出现方块悬空的情况
        # 当然,也可以在遍历的时候这样遍历:for m in rowsToRemove[-1:0]
        rowsToRemove.reverse()

        for m in rowsToRemove:
            # self.shapeAt(l, k + 1)获取要消除的行的上一行的方块形状,然后替换当前方块的形状
            for k in range(m, Board.BoardHeight):
                for l in range(Board.BoardWidth):
                        self.setShapeAt(l, k, self.shapeAt(l, k + 1))

        # 更新已经消除的行数
        # numFullLines = numFullLines + len(rowsToRemove)
        # 还可以改成这样,如果连续消除,则分数翻倍。
        numFullLines = numFullLines + int(math.pow(2, len(rowsToRemove))) - 1

        if numFullLines > 0:
            # 更新分数
            self.numLinesRemoved = self.numLinesRemoved + numFullLines
            self.msg2Statusbar.emit(str(self.numLinesRemoved))  # 改变状态栏分数的值
            # 在消除后还要将当前方块形状设置为空,然后刷新界面
            self.isWaitingAfterLine = True
            self.curPiece.setShape(Tetrominoe.NoShape)
            self.update()

这里我发现消除一行只加1分太单调了,所以改了一下规则,如果连续消除,则分数加倍。

numFullLines = numFullLines + int(math.pow(2, len(rowsToRemove))) - 1

b.方块下落

定时器每次刷新一次,方块下落一行。

# 在计时器事件里,要么是等一个方块下落完之后创建一个新的方块,要么是让一个方块直接落到底
    def timerEvent(self, event):
        '''handles timer event'''

        if event.timerId() == self.timer.timerId():
            # 如果在消除方块,说明方块已经下落到底部了,创建新的方块,否则下落一行
            if self.isWaitingAfterLine:
                self.isWaitingAfterLine = False
                self.newPiece()
            else:
                self.oneLineDown()

        else:
            super(Board, self).timerEvent(event)

oneLineDown()函数执行方块下落一行的操作。每下落一行,都会检测是否有可以消除的行。

    def oneLineDown(self):
        '''goes one line down with a shape'''
        # 调用self.tryMove()函数时,就已经表示方块下落一行了,每次下落到底部后,检查一下是否有能够消除的方块
        if not self.tryMove(self.curPiece, self.curX, self.curY - 1):
            self.pieceDropped()

c.方块直接落到底部

    def dropDown(self):
        '''drops down a shape'''
        # 获取当前行
        newY = self.curY
        # 当方块还没落到最底部时,尝试向下移动一行,同时当前行-1
        while newY > 0:

            if not self.tryMove(self.curPiece, self.curX, newY - 1):
                break

            newY -= 1
        # 移到底部时,检查是否能够消除方块
        self.pieceDropped()

方块落到底部,其实还一步一步下降到底部的过程,只不过这个过程是在一个定时器的时间内实现,所以在直观上来看就是直接落到了底部。

(7)暂停游戏

    # pause()方法用来暂停游戏,停止计时并在statusbar上显示一条信息
    def pause(self):
        '''pauses game'''
        # 如果有处于运行状态,则直接返回
        if not self.isStarted:
            return
        # 更改游戏的状态
        self.isPaused = not self.isPaused

        if self.isPaused:
            self.timer.stop()   # 停止计时
            self.msg2Statusbar.emit("paused")   # 发送暂停信号
        # 否则继续运行,显示分数
        else:
            self.timer.start(Board.Speed, self)
            self.msg2Statusbar.emit(str(self.numLinesRemoved))
        # 更新界面
        self.update()

暂停游戏的逻辑和启动游戏的逻辑差不多。

(8)游戏按键

   def keyPressEvent(self, event):
        '''processes key press events'''
        # 如果游戏不是开始状态或者方块形状为空,直接返回
        if not self.isStarted or self.curPiece.shape() == Tetrominoe.NoShape:
            super(Board, self).keyPressEvent(event)
            return

        key = event.key()
        # P代表暂停
        if key == Qt.Key_P:
            self.pause()
            return
        # 如果游戏处于暂停状态,则不触发按键(只对按键P生效)
        if self.isPaused:
            return
        # 方向键左键代表左移一个位置,x坐标-1
        elif key == Qt.Key_Left:
            self.tryMove(self.curPiece, self.curX - 1, self.curY)
        # 在keyPressEvent()方法获得用户按下的按键。如果按下的是右方向键,就尝试把方块向右移动,说尝试是因为有可能到边界不能移动了。
        # 方向键右键代表右移一个位置,x坐标+1
        elif key == Qt.Key_Right:
            self.tryMove(self.curPiece, self.curX + 1, self.curY)
        # 下方向键代表向右旋转
        elif key == Qt.Key_Down:
            self.tryMove(self.curPiece.rotateRight(), self.curX, self.curY)
        # 上方向键是把方块向左旋转一下
        elif key == Qt.Key_Up:
            self.tryMove(self.curPiece.rotateLeft(), self.curX, self.curY)
        # 空格键会直接把方块放到底部
        elif key == Qt.Key_Space:
            self.dropDown()
        # D键是加速一次下落速度
        elif key == Qt.Key_D:
            self.oneLineDown()

        else:
            super(Board, self).keyPressEvent(event)

设置了各个按键对应的操作,可更改。

六、一些小小的优化

新增了一个重启游戏的按键R。

# R代表重启游戏
        if key == Qt.Key_R:
            self.initBoard()
            self.start()

按R重启游戏,初始化Board且启动游戏。
在游戏暂停和结束后显示游戏当前的分数。

self.msg2Statusbar.emit(f"paused, current socre is {self.numLinesRemoved}")   # 发送暂停信号,同时显示当前分数
self.msg2Statusbar.emit(f"Game over, your socre is {self.numLinesRemoved}") # 状态栏显示游戏结束

本来还想要再新增一个启动游戏的按钮,因为每次打开游戏就直接启动了,有点没反应过来,但是总是报错,就没加了。

七、最终实现代码

'''
俄罗斯方块
'''
import math

from PyQt5.QtWidgets import QMainWindow, QFrame, QDesktopWidget, QApplication, QPushButton, QVBoxLayout
from PyQt5.QtCore import Qt, QBasicTimer, pyqtSignal
from PyQt5.QtGui import QPainter, QColor
import sys, random

class Tetris(QMainWindow):

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

        self.initUI()


    def initUI(self):
        '''initiates application UI'''
        # 创建了一个Board类的实例,并设置为应用的中心组件
        self.tboard = Board(self)
        self.setCentralWidget(self.tboard)
        # 创建一个statusbar来显示三种信息:消除的行数,游戏暂停状态或者游戏结束状态
        # msg2Statusbar是一个自定义的信号,用在(和)Board类(交互),showMessage()方法是一个内建的,用来在statusbar上显示信息的方法。
        self.statusbar = self.statusBar()
        self.tboard.msg2Statusbar[str].connect(self.statusbar.showMessage)

        self.tboard.start() # 初始化游戏

        # self.btn = QPushButton("开始游戏", self)
        # self.btn.clicked[bool].connect(self.start)
        #
        # vbox = QVBoxLayout(self)
        # vbox.addWidget(self.btn)
        # vbox.addWidget(self.tboard)
        #
        # self.setLayout(vbox)

        self.resize(213, 426)   #  设置窗口大小
        # self.setGeometry(300, 300, 500, 300)
        self.center()   # 窗口居中
        self.setWindowTitle('Tetris')   # 标题
        self.show() # 展示窗口


    def center(self):
        '''centers the window on the screen'''
        # screenGeometry()函数提供有关可用屏幕几何的信息
        screen = QDesktopWidget().screenGeometry()
        # 获取窗口坐标系
        size = self.geometry()
        # 将窗口放到中间
        self.move((screen.width()-size.width())//2,
            (screen.height()-size.height())//2)

class Board(QFrame):
    # 创建了一个自定义信号msg2Statusbar,当我们想往statusbar里显示信息的时候,发出这个信号就行了。
    msg2Statusbar = pyqtSignal(str)
    # 这些是Board类的变量。BoardWidth和BoardHeight分别是board的宽度和高度。Speed是游戏的速度,每300ms出现一个新的方块
    BoardWidth = 10 # 指界面宽度可以容纳10个小方块
    BoardHeight = 22    # 指界面高度可以容纳22个小方块
    Speed = 300

    def __init__(self, parent):
        super().__init__(parent)

        self.initBoard()


    def initBoard(self):
        '''initiates board'''

        self.timer = QBasicTimer()  # 定义了一个定时器
        self.isWaitingAfterLine = False # self.isWaitingAfterLine表示是否在等待消除行

        self.curX = 0   # 目前x坐标
        self.curY = 0   # 目前y坐标
        self.numLinesRemoved = 0    # 表示消除的行数,也就是分数
        self.board = [] # 存储每个方块位置的形状,默认应该为0,下标代表方块坐标x*y

        self.setFocusPolicy(Qt.StrongFocus) # 设置焦点,使用tab键和鼠标左键都可以获取焦点
        self.isStarted = False  # 表示游戏是否在运行状态
        self.isPaused = False   # 表示游戏是否在暂停状态
        self.clearBoard()   # 清空界面的全部方块

    # shapeAt()决定了board里方块的的种类。
    def shapeAt(self, x, y):
        '''determines shape at the board position'''
        # 返回的是(x,y)坐标方块在self.board中的值
        return self.board[(y * Board.BoardWidth) + x]


    def setShapeAt(self, x, y, shape):
        '''sets a shape at the board'''
        # 设置方块的形状,放入self.board中
        self.board[(y * Board.BoardWidth) + x] = shape

    # board的大小可以动态的改变。所以方格的大小也应该随之变化。squareWidth()计算并返回每个块应该占用多少像素--也即Board.BoardWidth。
    def squareWidth(self):
        '''returns the width of one square'''

        return self.contentsRect().width() // Board.BoardWidth


    def squareHeight(self):
        return self.contentsRect().height() // Board.BoardHeight

    # 开始游戏
    def start(self):
        '''starts game'''
        # 如果游戏处于暂停状态,直接返回
        if self.isPaused:
            return

        self.isStarted = True   # 将开始状态设置为True
        self.isWaitingAfterLine = False
        self.numLinesRemoved = 0    # 将分数设置为0
        self.clearBoard()   # 清空界面全部的方块
        # 状态栏显示当前有多少分
        self.msg2Statusbar.emit(str(self.numLinesRemoved))

        self.newPiece() # 创建一个新的方块
        self.timer.start(Board.Speed, self) # 开始计时,每过300ms刷新一次当前的界面

    # pause()方法用来暂停游戏,停止计时并在statusbar上显示一条信息
    def pause(self):
        '''pauses game'''
        # 如果有处于运行状态,则直接返回
        if not self.isStarted:
            return
        # 更改游戏的状态
        self.isPaused = not self.isPaused

        if self.isPaused:
            self.timer.stop()   # 停止计时
            self.msg2Statusbar.emit(f"paused, current socre is {self.numLinesRemoved}")   # 发送暂停信号,同时显示当前分数
        # 否则继续运行,显示分数
        else:
            self.timer.start(Board.Speed, self)
            self.msg2Statusbar.emit(str(self.numLinesRemoved))
        # 更新界面
        self.update()

    # 渲染是在paintEvent()方法里发生的QPainter负责PyQt5里所有低级绘画操作。
    def paintEvent(self, event):
        '''paints all shapes of the game'''

        painter = QPainter(self)    # 新建了一个QPainter对象
        rect = self.contentsRect()  # 获取内容区域
        # self.squareHeight()获取的是小方块的高度,不是很理解,猜测是方块出现后去获取方块的高度
        boardTop = rect.bottom() - Board.BoardHeight * self.squareHeight()  # 获取board中除去方块后多出来的空间
        # 渲染游戏分为两步。第一步是先画出所有已经落在最下面的的图,这些保存在self.board里。
        # 可以使用shapeAt()查看这个这个变量。
        for i in range(Board.BoardHeight):
            for j in range(Board.BoardWidth):
                # 返回存储在self.board里面的形状
                shape = self.shapeAt(j, Board.BoardHeight - i - 1)
                # 如果形状不是空,绘制方块
                if shape != Tetrominoe.NoShape:
                    # 绘制方块,rect.left()表示Board的左边距
                    self.drawSquare(painter,
                        rect.left() + j * self.squareWidth(),
                        boardTop + i * self.squareHeight(), shape)
        # 第二步是画出正在下落的方块
        # 获取目前方块的形状,不能为空
        if self.curPiece.shape() != Tetrominoe.NoShape:

            for i in range(4):
                # 计算在Board上的坐标,作为方块坐标原点(单位是小方块)
                x = self.curX + self.curPiece.x(i)
                y = self.curY - self.curPiece.y(i)
                # 绘制方块
                self.drawSquare(painter, rect.left() + x * self.squareWidth(),
                    boardTop + (Board.BoardHeight - y - 1) * self.squareHeight(),
                    self.curPiece.shape())


    def keyPressEvent(self, event):
        '''processes key press events'''

        key = event.key()

        # R代表重启游戏
        if key == Qt.Key_R:
            self.initBoard()
            self.start()

        # 如果游戏不是开始状态或者方块形状为空,直接返回
        if not self.isStarted or self.curPiece.shape() == Tetrominoe.NoShape:
            super(Board, self).keyPressEvent(event)
            return

        # P代表暂停
        if key == Qt.Key_P:
            self.pause()
            return
        # 如果游戏处于暂停状态,则不触发按键(只对按键P生效)
        if self.isPaused:
            return
        # 方向键左键代表左移一个位置,x坐标-1
        elif key == Qt.Key_Left:
            self.tryMove(self.curPiece, self.curX - 1, self.curY)
        # 在keyPressEvent()方法获得用户按下的按键。如果按下的是右方向键,就尝试把方块向右移动,说尝试是因为有可能到边界不能移动了。
        # 方向键右键代表右移一个位置,x坐标+1
        elif key == Qt.Key_Right:
            self.tryMove(self.curPiece, self.curX + 1, self.curY)
        # 下方向键代表向右旋转
        elif key == Qt.Key_Down:
            self.tryMove(self.curPiece.rotateRight(), self.curX, self.curY)
        # 上方向键是把方块向左旋转一下
        elif key == Qt.Key_Up:
            self.tryMove(self.curPiece.rotateLeft(), self.curX, self.curY)
        # 空格键会直接把方块放到底部
        elif key == Qt.Key_Space:
            self.dropDown()
        # D键是加速一次下落速度
        elif key == Qt.Key_D:
            self.oneLineDown()

        else:
            super(Board, self).keyPressEvent(event)

    # 在计时器事件里,要么是等一个方块下落完之后创建一个新的方块,要么是让一个方块直接落到底
    def timerEvent(self, event):
        '''handles timer event'''

        if event.timerId() == self.timer.timerId():
            # 如果在消除方块,说明方块已经下落到底部了,创建新的方块,否则下落一行
            if self.isWaitingAfterLine:
                self.isWaitingAfterLine = False
                self.newPiece()
            else:
                self.oneLineDown()

        else:
            super(Board, self).timerEvent(event)

    # clearBoard()方法通过Tetrominoe.NoShape清空broad
    def clearBoard(self):
        '''clears shapes from the board'''
        # 将界面每个小方块都设置为空,存储到self.board中,下标表示第几个方块,(x*y)
        for i in range(Board.BoardHeight * Board.BoardWidth):
            self.board.append(Tetrominoe.NoShape)


    def dropDown(self):
        '''drops down a shape'''
        # 获取当前行
        newY = self.curY
        # 当方块还没落到最底部时,尝试向下移动一行,同时当前行-1
        while newY > 0:

            if not self.tryMove(self.curPiece, self.curX, newY - 1):
                break

            newY -= 1
        # 移到底部时,检查是否能够消除方块
        self.pieceDropped()


    def oneLineDown(self):
        '''goes one line down with a shape'''
        # 调用self.tryMove()函数时,就已经表示方块下落一行了,每次下落到底部后,检查一下是否有能够消除的方块
        if not self.tryMove(self.curPiece, self.curX, self.curY - 1):
            self.pieceDropped()


    def pieceDropped(self):
        '''after dropping shape, remove full lines and create new shape'''
        # 将方块的形状添加到self.board中,非0代表该处有方块
        for i in range(4):
            # 获取每个小方块的坐标
            x = self.curX + self.curPiece.x(i)
            y = self.curY - self.curPiece.y(i)
            self.setShapeAt(x, y, self.curPiece.shape())
        # 移除满行的方块
        self.removeFullLines()
        # self.isWaitingAfterLine表示是否在等待消除行,如果不在等待就新建一个方块
        if not self.isWaitingAfterLine:
            self.newPiece()

    # 如果方块碰到了底部,就调用removeFullLines()方法,找到所有能消除的行消除它们。
    # 消除的具体动作就是把符合条件的行消除掉之后,再把它上面的行下降一行。
    # 注意移除满行的动作是倒着来的,因为我们是按照重力来表现游戏的,如果不这样就有可能出现有些方块浮在空中的现象
    def removeFullLines(self):
        '''removes all full lines from the board'''

        numFullLines = 0    # 记录消除的行数
        rowsToRemove = []   # 要消除的行列表

        for i in range(Board.BoardHeight):  # 遍历每一行

            n = 0
            for j in range(Board.BoardWidth): # 遍历整行的方块
                # 如果self.board里面的值不为空,计数
                if not self.shapeAt(j, i) == Tetrominoe.NoShape:
                    n = n + 1
            # 如果整行都有方块,将要消除的行添加进数组中
            if n == Board.BoardWidth:   # 原文是 n == 10,但我觉得该成n == Board.BoardWidth会更严谨一点
                rowsToRemove.append(i)
        # 因为是从上往下遍历,所以要倒过来消除,否则会出现方块悬空的情况
        # 当然,也可以在遍历的时候这样遍历:for m in rowsToRemove[-1:0]
        rowsToRemove.reverse()

        for m in rowsToRemove:
            # self.shapeAt(l, k + 1)获取要消除的行的上一行的方块形状,然后替换当前方块的形状
            for k in range(m, Board.BoardHeight):
                for l in range(Board.BoardWidth):
                        self.setShapeAt(l, k, self.shapeAt(l, k + 1))

        # 更新已经消除的行数
        # numFullLines = numFullLines + len(rowsToRemove)
        # 还可以改成这样,如果连续消除,则分数翻倍。
        numFullLines = numFullLines + int(math.pow(2, len(rowsToRemove))) - 1

        if numFullLines > 0:
            # 更新分数
            self.numLinesRemoved = self.numLinesRemoved + numFullLines
            self.msg2Statusbar.emit(str(self.numLinesRemoved))  # 改变状态栏分数的值
            # 在消除后还要将当前方块形状设置为空,然后刷新界面
            self.isWaitingAfterLine = True
            self.curPiece.setShape(Tetrominoe.NoShape)
            self.update()

    # newPiece()方法是用来创建形状随机的方块。如果随机的方块不能正确的出现在预设的位置,游戏结束。
    def newPiece(self):
        '''creates a new shape'''

        self.curPiece = Shape() # 创建了一个Shape对象
        self.curPiece.setRandomShape()  # 设置了一个随机的形状
        self.curX = Board.BoardWidth // 2 + 1   # 以界面中心为起点
        self.curY = Board.BoardHeight - 1 + self.curPiece.minY() # 从这里看应该是预留了一行的高度,但不知道作用是什么
        # 判断是否还有空位,如果没有
        if not self.tryMove(self.curPiece, self.curX, self.curY):
            # 将当前形状设置为空
            self.curPiece.setShape(Tetrominoe.NoShape)
            self.timer.stop()   # 停止计时
            self.isStarted = False  # 将开始状态设置为False
            self.msg2Statusbar.emit(f"Game over, your socre is {self.numLinesRemoved}") # 状态栏显示游戏结束


    # tryMove()是尝试移动方块的方法。
    # 如果方块已经到达board的边缘或者遇到了其他方块,就返回False。否则就把方块下落到想要的位置
    def tryMove(self, newPiece, newX, newY):
        '''tries to move a shape'''

        for i in range(4):
            # newPiece是一个Shape对象,newX,newY相当于坐标原点(相对于方块而言)
            x = newX + newPiece.x(i)    # 得到每个小方块在界面上的坐标
            y = newY - newPiece.y(i)
            # 超出边界则返回False
            if x < 0 or x >= Board.BoardWidth or y < 0 or y >= Board.BoardHeight:
                return False
            # 如果方块位置不为0,说明已经用过了,不允许使用,返回False
            if self.shapeAt(x, y) != Tetrominoe.NoShape:
                return False

        self.curPiece = newPiece    # 更新当前的方块形状
        self.curX = newX    # 更新当前的坐标
        self.curY = newY
        self.update()   # 更新窗口,同时调用paintEvent()函数

        return True


    def drawSquare(self, painter, x, y, shape):
        '''draws a square of a shape'''

        colorTable = [0x000000, 0xCC6666, 0x66CC66, 0x6666CC,
                      0xCCCC66, 0xCC66CC, 0x66CCCC, 0xDAAA00]
        # 为每种形状的方块设置不同的颜色
        color = QColor(colorTable[shape])
        # 参数分别为x,y,w,h,color,填充了颜色
        painter.fillRect(x + 1, y + 1, self.squareWidth() - 2,
            self.squareHeight() - 2, color)

        painter.setPen(color.lighter())
        # 画线,从起始坐标到终点坐标,-1是为了留一点空格,看起来更有立体感
        painter.drawLine(x, y + self.squareHeight() - 1, x, y) # 左边那条线
        painter.drawLine(x, y, x + self.squareWidth() - 1, y)   # 上边那条线
        # 换了画笔的样式,同样是为了让图案看起来更有立体感
        painter.setPen(color.darker())
        painter.drawLine(x + 1, y + self.squareHeight() - 1,
            x + self.squareWidth() - 1, y + self.squareHeight() - 1)    # 下边那条线
        painter.drawLine(x + self.squareWidth() - 1,
            y + self.squareHeight() - 1, x + self.squareWidth() - 1, y + 1) # 右边那条线

# Tetrominoe类保存了所有方块的形状。我们还定义了一个NoShape的空形状。
class Tetrominoe(object):
    # 和Shape类里的coordsTable数组一一对应
    NoShape = 0
    ZShape = 1
    SShape = 2
    LineShape = 3
    TShape = 4
    SquareShape = 5
    LShape = 6
    MirroredLShape = 7

# Shape类保存类方块内部的信息。
class Shape(object):
    # coordsTable元组保存了所有的方块形状的组成。是一个构成方块的坐标模版。
    coordsTable = (
        ((0, 0),     (0, 0),     (0, 0),     (0, 0)),   # 空方块
        ((0, -1),    (0, 0),     (-1, 0),    (-1, 1)),
        ((0, -1),    (0, 0),     (1, 0),     (1, 1)),
        ((0, -1),    (0, 0),     (0, 1),     (0, 2)),
        ((-1, 0),    (0, 0),     (1, 0),     (0, 1)),
        ((0, 0),     (1, 0),     (0, 1),     (1, 1)),
        ((-1, -1),   (0, -1),    (0, 0),     (0, 1)),
        ((1, -1),    (0, -1),    (0, 0),     (0, 1))
    )

    def __init__(self):
        # 下面创建了一个新的空坐标数组,这个数组将用来保存方块的坐标。
        self.coords = [[0,0] for i in range(4)]     # 4x4的二维数组,每个元素代表方块的左上角坐标
        self.pieceShape = Tetrominoe.NoShape    # 方块形状,初始形状为空白

        self.setShape(Tetrominoe.NoShape)

    # 返回当前方块形状
    def shape(self):
        '''returns shape'''

        return self.pieceShape

    # 设置方块形状
    def setShape(self, shape):  # 初始shape为0
        '''sets a shape'''

        table = Shape.coordsTable[shape]    # 从形状列表里取出其中一个方块的形状,为一个4x2的数组

        for i in range(4):
            for j in range(2):
                self.coords[i][j] = table[i][j] # 赋给要使用的方块元素

        self.pieceShape = shape # 再次获取形状(index)

    # 设置一个随机的方块形状
    def setRandomShape(self):
        '''chooses a random shape'''

        self.setShape(random.randint(1, 7))

    # 小方块的x坐标,index代表第几个方块
    def x(self, index):
        '''returns x coordinate'''

        return self.coords[index][0]

    # 小方块的y坐标
    def y(self, index):
        '''returns y coordinate'''

        return self.coords[index][1]

    # 设置小方块的x坐标
    def setX(self, index, x):
        '''sets x coordinate'''

        self.coords[index][0] = x

    # 设置小方块的y坐标
    def setY(self, index, y):
        '''sets y coordinate'''

        self.coords[index][1] = y

    # 找出方块形状中位于最左边的方块的x坐标
    def minX(self):
        '''returns min x value'''

        m = self.coords[0][0]
        for i in range(4):
            m = min(m, self.coords[i][0])

        return m

    # 找出方块形状中位于最右边的方块的x坐标
    def maxX(self):
        '''returns max x value'''

        m = self.coords[0][0]
        for i in range(4):
            m = max(m, self.coords[i][0])

        return m

    # 找出方块形状中位于最左边的方块的y坐标
    def minY(self):
        '''returns min y value'''

        m = self.coords[0][1]
        for i in range(4):
            m = min(m, self.coords[i][1])

        return m

    # 找出方块形状中位于最右边的方块的y坐标
    def maxY(self):
        '''returns max y value'''

        m = self.coords[0][1]
        for i in range(4):
            m = max(m, self.coords[i][1])

        return m

    # rotateLeft()方法向右旋转一个方块。正方形的方块就没必要旋转,就直接返回了。
    # 其他的是返回一个新的,能表示这个形状旋转了的坐标。
    def rotateLeft(self):
        '''rotates shape to the left'''
        # 正方形没有必要旋转
        if self.pieceShape == Tetrominoe.SquareShape:
            return self
        # 获取当前的方块形状
        result = Shape()
        result.pieceShape = self.pieceShape
        # 向左旋转,相当将坐标轴向左旋转了,和原来的坐标轴想比 (x,y) -> (y,-x)
        for i in range(4):  # i代表第几个小方块
            result.setX(i, self.y(i))   # 设置第i个方块的x坐标,
            result.setY(i, -self.x(i))  # 设置第i个方块的x坐标

        return result

    # 向右旋转,同理,(x,y) -> (-y,x)
    def rotateRight(self):
        '''rotates shape to the right'''

        if self.pieceShape == Tetrominoe.SquareShape:
            return self

        result = Shape()
        result.pieceShape = self.pieceShape

        for i in range(4):
            result.setX(i, -self.y(i))
            result.setY(i, self.x(i))

        return result


if __name__ == '__main__':

    app = QApplication([])
    tetris = Tetris()
    sys.exit(app.exec_())

八、打包成可执行文件

使用Python的pyinstaller打包,安装命令如下

pip install pyinstaller

安装完后,为了让程序更加完善,我们添加一个图标。
在程序同目录下新建resource.txt文件,添加如下内容。

<RCC>
    <qresource prefix="/">
        <file>a.ico</file>
    </qresource>
</RCC>

其中a.ico是你要用的图标,注意要在同目录下,百度找图片转ico的网站。
保存后更改文件后缀名为resource.qrc,(pyrcc5是PyQt5的附带工具)具体参见:https://blog.csdn.net/qq_45662588/article/details/118187345
使用pyrcc5进行转换得到.py文件。

Name:qrcTopy(可以自己确定)
Program:D:\Python38\Scripts\pyrcc5.exe
Arguments:$FileName$ -o $FileNameWithoutExtension$_rc.py
Working directory:$FileDir$


配置好后利用pyrcc5工具把上面的resource.qrc文件转成resource.py,运行命令。

pyrcc5 -o resource.py resource.qrc

之后打包程序。

pyinstaller -w -i a.ico Tetris_game.py

打包完成后运行程序。

完成。

九、总结

俄罗斯方块虽然是一个比较简单的游戏,但是从这一个简单的游戏中就能看出很多编程的思想。包括数学建模,将界面看成一个二维的坐标轴,坐标轴单位其实是一个小方块,这样看起来会更直观一点,且也能固定方块的大小,而不会因为窗口大小的改变而留下一大片空白,在具体的界面展示时再计算实际的坐标。
将每个形状的方块都抽象为一个个坐标,存放到数组中,同时用一个数组来存储已经到达底部的方块,每次刷新后根据这个数组重新绘制界面。

评论关闭