pyqt5教程推荐(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.BoardHeight和Board.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))) - 1b.方块下落
定时器每次刷新一次 ,方块下落一行 。
# 在计时器事件里 ,要么是等一个方块下落完之后创建一个新的方块 ,要么是让一个方块直接落到底 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_())八 、总结
俄罗斯方块虽然是一个比较简单的游戏 ,但是从这一个简单的游戏中就能看出很多编程的思想 。包括数学建模 ,将界面看成一个二维的坐标轴 ,坐标轴单位其实是一个小方块 ,这样看起来会更直观一点 ,且也能固定方块的大小 ,而不会因为窗口大小的改变而留下一大片空白,在具体的界面展示时再计算实际的坐标 。
将每个形状的方块都抽象为一个个坐标 ,存放到数组中 ,同时用一个数组来存储已经到达底部的方块,每次刷新后根据这个数组重新绘制界面 。创心域SEO版权声明:以上内容作者已申请原创保护,未经允许不得转载,侵权必究!授权事宜、对本内容有异议或投诉,敬请联系网站管理员,我们将尽快回复您,谢谢合作!