一、前言

最近学习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_())

八、总结

俄罗斯方块虽然是一个比较简单的游戏,但是从这一个简单的游戏中就能看出很多编程的思想。包括数学建模,将界面看成一个二维的坐标轴,坐标轴单位其实是一个小方块,这样看起来会更直观一点,且也能固定方块的大小,而不会因为窗口大小的改变而留下一大片空白,在具体的界面展示时再计算实际的坐标。

将每个形状的方块都抽象为一个个坐标,存放到数组中,同时用一个数组来存储已经到达底部的方块,每次刷新后根据这个数组重新绘制界面。

pyqt5制作俄罗斯方块小游戏-----源码解析的更多相关文章

  1. HTML5小游戏源码收藏

    html5魅族创意的贪食蛇游戏源码下载 html5网页版打砖块小游戏源码下载 html5 3D立体魔方小游戏源码下载 html5网页版飞机躲避游戏源码下载 html5三国人物连连看游戏源码下载 js ...

  2. Creator仿超级玛丽小游戏源码分享

    Creator仿超级玛丽小游戏源码分享 之前用Cocos Creator 做的一款仿超级玛丽的游戏,使用的版本为14.2 ,可以直接打包为APK,现在毕设已经完成,游戏分享出来,大家一起学习进步.特别 ...

  3. 基于html5制作3D拳击游戏源码下载

    今天给大家分享一款基于HTML5实现的3d拳王游戏源码.这款实例适用浏览器:360.FireFox.Chrome.Safari.Opera.傲游.搜狗.世界之窗. 不支持IE8及以下浏览器. 在线预览 ...

  4. flappy pig小游戏源码分析(1)——主程序初探

    闲逛github发现一个javascript原生实现的小游戏,源码写的很清晰,适合想提高水平的同学观摩学习.读通源码后,我决定写一系列的博客来分析源码,从整体架构到具体实现细节来帮助一些想提高水平的朋 ...

  5. flappy pig小游戏源码分析(4)——核心pig模块(未完待续)

    热身之后,我们要动点真格的了,游戏叫flappy pig,我们的pig终于要出场了. 老规矩,看看目录结构,读者对着目录结构好好回想我们已经讲解的几个模块: 其中game.js是游戏主程序,optio ...

  6. h5小球走迷宫小游戏源码

    无意中找到的一个挺有意思的小游戏,关键是用h5写的,下面就分享给大家源码 还是先来看小游戏的截图 可以用键盘的三个键去控制它,然后通关 下面是源代码 <!doctype html> < ...

  7. flappy pig小游戏源码分析(3)——解剖util

    这一节我们继续高歌猛进,如果对源码中有无论无何都理解不通的问题,欢迎和我交流,让我也学习一下,我的qq是372402487. 还是按照惯例看看我们的目录结构. 我们在前两节中已经分析了game.js, ...

  8. flappy pig小游戏源码分析(2)——解剖option

    今天继续分析flappy bird的源码.重温一下源码的目录结构. 在本系列第一篇中我们分析了game.js文件,也就是整个程序的架构.这一篇我们来看看option.js文件,这个文件的内容很简单,主 ...

  9. xss小游戏源码分析

    配置 下载地址:https://files.cnblogs.com/files/Lmg66/xssgame-master.zip 使用:下载解压,放到www目录下(phpstudy),http服务下都 ...

  10. C\C++ 1A2B小游戏源码

    学了一段时间,心血来潮写了一个1A2B小游戏,很多人应该玩过,是一个挺有意思的益智小游戏,之前用易语言写过,现在又用C++重写了一下. 编译运行无错,整体程序设计思路为:进入循环,初始化游戏,读入一个 ...

随机推荐

  1. MySQL数据库-数据表(下)

    分析:给 reader 表添加数据. INSERT INTO:插入数据,插入数据的时候会检查主键或者唯一索引,如果出现重复就会报错: 语法:INSERT INTO table_name VALUES ...

  2. jq判断页面滚动条进行样式修改

    $(window).scroll(function(){//窗口的滚动条 if($(window).scrollTop()>100){ //垂直滚动条钓offset 大于90时. $(" ...

  3. 关于docker创建容器报错-docker: Error response from daemon: runtime "io.containerd.runc.v2" binary not installed

    今天在对一台服务器(docker相关的业务服务器)进行OS补丁时,默认使用的 yum update -y 对所有的安装包进行了升级 升级完成后,让应用方检查确认应用及功能是否一切正常,如果不正常,严重 ...

  4. PAT520 钻石争霸赛 7-6 随机输一次

    #include<bits/stdc++.h> using namespace std; typedef long long ll; const int maxn = 1000; ll n ...

  5. 如何在服务器上部署WebDeploy

    之前项目中网站发布都是手工拷贝文件,特别麻烦,看到好多用webdeploy一键部署网站到IIS服务器,我也学习了一下. 第一步,打开服务器管理器 打开方式是开始菜单=>管理工具=>服务器管 ...

  6. Qt class 前置声明

    转载:https://www.cnblogs.com/ycbeginner/p/9403976.html 在Qt开发项目中,经常会用到各种库,但是一般在.h文件中进行某类型变量定义时,都会对其类型的c ...

  7. 如何用AR Engine环境Mesh能力实现虚实遮挡

    在AR应用中,用户最不希望看到不真实的穿模现象发生,如虚拟形象部分身体陷入墙壁之中,或者未碰到墙壁却已无法移动,这种不真实的交互十分影响用户体验.那如何才能让避免虚拟物体的穿模问题呢?使用AR Eng ...

  8. jsp页面重定向后地址栏controller名重复而导致报404错误

    今天做ssm项目时遇到了这种错误 看看代码: 无关代码省略... 22 <body> 23 <div id="container"> 24 <ifra ...

  9. Java I/O(3):NIO中的Buffer

    您好,我是湘王,这是我的博客园,欢迎您来,欢迎您再来- 之前在调用Channel的代码中,使用了一个名叫ByteBuffer类,它是Buffer的子类.这个叫Buffer的类是专门用来解决高速设备与低 ...

  10. 在Centos7上安装JDK1.8和tomcat8.5的步骤和过程(亲测可用)

    文章目录 1.在阿里云(Linux)Centos7上安装JDK1.8步骤过程 2.阿里云(Linux)centos7上安装Tomcat8.5步骤过程记录: 卸载JDK Centos7一般都会带有自己的 ...