一、前言

最近学习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 = 0ZShape = 1SShape = 2LineShape = 3TShape = 4SquareShape = 5LShape = 6MirroredLShape = 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 selfresult = Shape()result.pieceShape = self.pieceShapefor 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 = 300def __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*yself.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清空broaddef 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:returnself.isStarted = True   # 将开始状态设置为Trueself.isWaitingAfterLine = Falseself.numLinesRemoved = 0    # 将分数设置为0self.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  # 将开始状态设置为Falseself.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)# 超出边界则返回Falseif x < 0 or x >= Board.BoardWidth or y < 0 or y >= Board.BoardHeight:return False# 如果方块位置不为0,说明已经用过了,不允许使用,返回Falseif self.shapeAt(x, y) != Tetrominoe.NoShape:return Falseself.curPiece = newPiece    # 更新当前的方块形状self.curX = newX    # 更新当前的坐标self.curY = newYself.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.BoardWidthdef 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 = 0for 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))) - 1if numFullLines > 0:# 更新分数self.numLinesRemoved = self.numLinesRemoved + numFullLinesself.msg2Statusbar.emit(str(self.numLinesRemoved))  # 改变状态栏分数的值# 在消除后还要将当前方块形状设置为空,然后刷新界面self.isWaitingAfterLine = Trueself.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 = Falseself.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# 当方块还没落到最底部时,尝试向下移动一行,同时当前行-1while newY > 0:if not self.tryMove(self.curPiece, self.curX, newY - 1):breaknewY -= 1# 移到底部时,检查是否能够消除方块self.pieceDropped()

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

(7)暂停游戏

    # pause()方法用来暂停游戏,停止计时并在statusbar上显示一条信息def pause(self):'''pauses game'''# 如果有处于运行状态,则直接返回if not self.isStarted:return# 更改游戏的状态self.isPaused = not self.isPausedif 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)returnkey = event.key()# P代表暂停if key == Qt.Key_P:self.pause()return# 如果游戏处于暂停状态,则不触发按键(只对按键P生效)if self.isPaused:return# 方向键左键代表左移一个位置,x坐标-1elif key == Qt.Key_Left:self.tryMove(self.curPiece, self.curX - 1, self.curY)# 在keyPressEvent()方法获得用户按下的按键。如果按下的是右方向键,就尝试把方块向右移动,说尝试是因为有可能到边界不能移动了。# 方向键右键代表右移一个位置,x坐标+1elif 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 mathfrom PyQt5.QtWidgets import QMainWindow, QFrame, QDesktopWidget, QApplication, QPushButton, QVBoxLayout
from PyQt5.QtCore import Qt, QBasicTimer, pyqtSignal
from PyQt5.QtGui import QPainter, QColor
import sys, randomclass 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 = 300def __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*yself.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.BoardWidthdef squareHeight(self):return self.contentsRect().height() // Board.BoardHeight# 开始游戏def start(self):'''starts game'''# 如果游戏处于暂停状态,直接返回if self.isPaused:returnself.isStarted = True   # 将开始状态设置为Trueself.isWaitingAfterLine = Falseself.numLinesRemoved = 0    # 将分数设置为0self.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.isPausedif 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坐标-1elif key == Qt.Key_Left:self.tryMove(self.curPiece, self.curX - 1, self.curY)# 在keyPressEvent()方法获得用户按下的按键。如果按下的是右方向键,就尝试把方块向右移动,说尝试是因为有可能到边界不能移动了。# 方向键右键代表右移一个位置,x坐标+1elif 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 = Falseself.newPiece()else:self.oneLineDown()else:super(Board, self).timerEvent(event)# clearBoard()方法通过Tetrominoe.NoShape清空broaddef 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# 当方块还没落到最底部时,尝试向下移动一行,同时当前行-1while newY > 0:if not self.tryMove(self.curPiece, self.curX, newY - 1):breaknewY -= 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 = 0for 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))) - 1if numFullLines > 0:# 更新分数self.numLinesRemoved = self.numLinesRemoved + numFullLinesself.msg2Statusbar.emit(str(self.numLinesRemoved))  # 改变状态栏分数的值# 在消除后还要将当前方块形状设置为空,然后刷新界面self.isWaitingAfterLine = Trueself.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  # 将开始状态设置为Falseself.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)# 超出边界则返回Falseif x < 0 or x >= Board.BoardWidth or y < 0 or y >= Board.BoardHeight:return False# 如果方块位置不为0,说明已经用过了,不允许使用,返回Falseif self.shapeAt(x, y) != Tetrominoe.NoShape:return Falseself.curPiece = newPiece    # 更新当前的方块形状self.curX = newX    # 更新当前的坐标self.curY = newYself.update()   # 更新窗口,同时调用paintEvent()函数return Truedef 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 = 0ZShape = 1SShape = 2LineShape = 3TShape = 4SquareShape = 5LShape = 6MirroredLShape = 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 selfresult = Shape()result.pieceShape = self.pieceShapefor i in range(4):result.setX(i, -self.y(i))result.setY(i, self.x(i))return resultif __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

打包完成后运行程序。

完成。

九、总结

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

pyqt5制作俄罗斯方块小游戏-----源码解析相关推荐

  1. Java swing实现的俄罗斯方块小游戏源码附带视频导入运行教程

    大家好,今天给大家演示一下由Java swing实现的小游戏俄罗斯方块的运行过程,该小游戏虽小,但功能非常丰富,可以设置速度.颜色.声音等等,是学习Java开发的童鞋参考学习不可多得的好项目.该程序可 ...

  2. 《游戏学习》Java版俄罗斯方块小游戏源码实战

    [Java版俄罗斯方块]     增加保存配置信息到文件的功能,声音设置.显示设置.关卡选择等配置信息在修改后将会保存在jar包同级目录下(以jar相同的文件名+.cfg后缀保存) [菜单选项]    ...

  3. android扫雷小游戏制作,android扫雷小游戏源码

    [实例简介] 一个简易的扫雷小游戏源码,可供android初学者参考学习 [实例截图] [核心代码] MineSweep └── MineSweep ├── AndroidManifest.xml ├ ...

  4. 学生学python编程---实现贪吃蛇小游戏+源码

    学生学python编程---实现贪吃蛇小游戏+源码 前言 主要设计 1.蛇的表示 2.蛇怎么移动? 3.玩家控制小蛇移动功能的实现 4.如何判定游戏结束? 应用知识点 1.python知识点 1.1 ...

  5. 【180928】美女贪吃蛇小游戏源码

    本源码是一个简单的c#版美女贪吃蛇小游戏源码,基于winform技术制作.控制方向键即可.右侧有记分板,每走一步都记加分.贪吃蛇身体掠过的地方就会显示背景图片,身体越长,显示的越多,玩家可以将图片换成 ...

  6. 合成大西瓜小程序小游戏源码

    ​ 近日,一款名为[合成大西瓜]的休闲小游戏火爆社交圈.[合成大西瓜]因其玩法简单.充满魔性而频登微博热搜,游戏开发商微伞小游戏在昨日顺势上线了"合成大西瓜"APP. 起初,大家对 ...

  7. 合成大西瓜小程序小游戏源码,仅供学习

    近日,一款名为[合成大西瓜]的休闲小游戏火爆社交圈.[合成大西瓜]因其玩法简单.充满魔性而频登微博热搜,游戏开发商微伞小游戏在昨日顺势上线了"合成大西瓜"APP. 起初,大家对这种 ...

  8. 11款手机微信小游戏源码特效

    html5微信吃苹果游戏源码下载 html5手机淘宝万能时装屋小游戏源码下载 html5 3d拳王游戏制作3D拳击游戏源码下载 html5 3d拼图游戏制作3D魔方游戏源码下载 htm5 3d游戏制作 ...

  9. HTML源码-网页123算数题小游戏源码

    介绍: HTML源码-网页数学小游戏-123算数题小游戏源码 网盘下载地址: http://kekewangLuo.net/RVZo8EC4yKu0 图片:

最新文章

  1. C++ ORM ODB 入门介绍(一)
  2. android 定位的几种方式介绍
  3. 领先微软技术咨询公司招聘技术人员
  4. Hbase 学习(三)Coprocessors
  5. 栈溢出笔记1.3 准备Shellcode
  6. 软件测试项目计划书总结,软件测试项目计划书.doc
  7. bzoj1010[HNOI2008]玩具装箱toy 斜率优化dp
  8. 模拟电子技术不挂科学习笔记1(半导体基础、二极管)
  9. oracle虚拟机怎么拖到windows,如何使用Virtualbox从Windows 10拖放到Ubuntu 18 VM
  10. 为什么都建议学java而不是python-为什么java比python复杂,还是有很多人选择学习java?...
  11. 聚焦2016:关于语音识别、图像识别及大数据
  12. android java反编译
  13. linux下怎样创建文本文件,如何使用Linux中的命令行快速创建文本文件 | MOS86
  14. TCPMP之旅(一) TCPMP整体软体框架
  15. 区块链底层架构概览:第一原则框架
  16. cin.get()的用法
  17. Rockchip USB FFS Test Demo 使用说明
  18. 我的FLASH情结2010——浅谈FLASH WEB GAME与创业(2)
  19. 单词串联记忆 - 故事 - Party上浪漫的事后传
  20. 小论工具类App的盈利之道

热门文章

  1. 大话Ceph--PG那点事儿
  2. TIDB在伴鱼的实践
  3. EHR人力资源项目总结
  4. javaScript的基本语法结构
  5. NXP公司K20+PF8100实现硬件窗口看门狗
  6. 做自己的奴隶还是自己的主人?不掌控命运,就被命运掌控
  7. 人工智能的深度学习,我将如何面对将来?
  8. 笔记本电脑键盘进水怎么办?有效处理湿键盘的关键步骤
  9. [附源码]Java计算机毕业设计SSM爱心互助及物品回收管理系统
  10. Enemy AI Unity2D-平台巡逻移动小怪