Tetris
This chapter implements a Tetris game.
Introduction
The Tetris game is one of the most popular computer games of all time. The original game was designed and written by Russian programmer Alexey Pajitnov in 1985. Since then, Tetris has appeared in many forms on almost every platform.
Tetris is known as falling cubes puzzle game. In this game we have 7 different shapes called bricks (tetrminoes): S-shape, Z-shape, T-shape, L-shape, line, reverse L-shape and square. Each shape is made up of four squares. These shapes fall from the top. The goal of the Tetris game is to move and rotate the shapes to fit them together as much as possible, and if they are in a line, the line disappears, thus scoring points, until the blocks are stacked to the top and the game is over.
Graphic: Tetrominoes
PyQt6 is aimed at creating applications, and some other libraries are aimed at creating computer games. Nevertheless, PyQt6 and other libraries can also be used to create simple games.
Making a computer game is a great way to improve your programming skills.
to develop
Because there is no picture of the game bricks, the drawing API in the PyQt6 programming toolkit is used here to draw the bricks. There is a mathematical model behind every computer game, and so is Tetris.
Some ideas:
QtCore.QBasicTimer
Create a game loop using- draw bricks
- Bricks are rotated or moved as a whole (not individually)
- Mathematically, the game board is a simple list of numbers
The code contains four classes: Tetris
, Board
, Tetrominoe
and Shape
. Tetris
The class sets up the game. Board
is where the game logic is written. Tetrominoe
The class contains the names of all the Tetris blocks, and Shape
the class contains the Tetris code.
# files: tetris.py
#!/usr/bin/python
"""
ZetCode PyQt6 tutorial
This is a Tetris game clone.
Author: Jan Bodnar
Website: zetcode.com
"""
import random
import sys
from PyQt6.QtCore import Qt, QBasicTimer, pyqtSignal
from PyQt6.QtGui import QPainter, QColor
from PyQt6.QtWidgets import QMainWindow, QFrame, QApplication
class Tetris(QMainWindow):
def __init__(self):
super().__init__()
self.initUI()
def initUI(self):
"""initiates application UI"""
self.tboard = Board(self)
self.setCentralWidget(self.tboard)
self.statusbar = self.statusBar()
self.tboard.msg2Statusbar[str].connect(self.statusbar.showMessage)
self.tboard.start()
self.resize(180, 380)
self.center()
self.setWindowTitle('Tetris')
self.show()
def center(self):
"""centers the window on the screen"""
qr = self.frameGeometry()
cp = self.screen().availableGeometry().center()
qr.moveCenter(cp)
self.move(qr.topLeft())
class Board(QFrame):
msg2Statusbar = pyqtSignal(str)
BoardWidth = 10
BoardHeight = 22
Speed = 300
def __init__(self, parent):
super().__init__(parent)
self.initBoard()
def initBoard(self):
"""initiates board"""
self.timer = QBasicTimer()
self.isWaitingAfterLine = False
self.curX = 0
self.curY = 0
self.numLinesRemoved = 0
self.board = []
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.isStarted = False
self.isPaused = False
self.clearBoard()
def shapeAt(self, x, y):
"""determines shape at the board position"""
return self.board[(y * Board.BoardWidth) + x]
def setShapeAt(self, x, y, shape):
"""sets a shape at the board"""
self.board[(y * Board.BoardWidth) + x] = shape
def squareWidth(self):
"""returns the width of one square"""
return self.contentsRect().width() // Board.BoardWidth
def squareHeight(self):
"""returns the height of one square"""
return self.contentsRect().height() // Board.BoardHeight
def start(self):
"""starts game"""
if self.isPaused:
return
self.isStarted = True
self.isWaitingAfterLine = False
self.numLinesRemoved = 0
self.clearBoard()
self.msg2Statusbar.emit(str(self.numLinesRemoved))
self.newPiece()
self.timer.start(Board.Speed, self)
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()
def paintEvent(self, event):
"""paints all shapes of the game"""
painter = QPainter(self)
rect = self.contentsRect()
boardTop = rect.bottom() - Board.BoardHeight * self.squareHeight()
for i in range(Board.BoardHeight):
for j in range(Board.BoardWidth):
shape = self.shapeAt(j, Board.BoardHeight - i - 1)
if shape != Tetrominoe.NoShape:
self.drawSquare(painter,
rect.left() + j * self.squareWidth(),
boardTop + i * self.squareHeight(), shape)
if self.curPiece.shape() != Tetrominoe.NoShape:
for i in range(4):
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"""
if not self.isStarted or self.curPiece.shape() == Tetrominoe.NoShape:
super(Board, self).keyPressEvent(event)
return
key = event.key()
if key == Qt.Key.Key_P:
self.pause()
return
if self.isPaused:
return
elif key == Qt.Key.Key_Left.value:
self.tryMove(self.curPiece, self.curX - 1, self.curY)
elif key == Qt.Key.Key_Right.value:
self.tryMove(self.curPiece, self.curX + 1, self.curY)
elif key == Qt.Key.Key_Down.value:
self.tryMove(self.curPiece.rotateRight(), self.curX, self.curY)
elif key == Qt.Key.Key_Up.value:
self.tryMove(self.curPiece.rotateLeft(), self.curX, self.curY)
elif key == Qt.Key.Key_Space.value:
self.dropDown()
elif key == Qt.Key.Key_D.value:
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)
def clearBoard(self):
"""clears shapes from the board"""
for i in range(Board.BoardHeight * Board.BoardWidth):
self.board.append(Tetrominoe.NoShape)
def dropDown(self):
"""drops down a shape"""
newY = self.curY
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"""
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"""
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()
if not self.isWaitingAfterLine:
self.newPiece()
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):
if not self.shapeAt(j, i) == Tetrominoe.NoShape:
n = n + 1
if n == 10:
rowsToRemove.append(i)
rowsToRemove.reverse()
for m in rowsToRemove:
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)
if numFullLines > 0:
self.numLinesRemoved = self.numLinesRemoved + numFullLines
self.msg2Statusbar.emit(str(self.numLinesRemoved))
self.isWaitingAfterLine = True
self.curPiece.setShape(Tetrominoe.NoShape)
self.update()
def newPiece(self):
"""creates a new shape"""
self.curPiece = 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
self.msg2Statusbar.emit("Game over")
def tryMove(self, newPiece, newX, newY):
"""tries to move a shape"""
for i in range(4):
x = newX + newPiece.x(i)
y = newY - newPiece.y(i)
if x < 0 or x >= Board.BoardWidth or y < 0 or y >= Board.BoardHeight:
return False
if self.shapeAt(x, y) != Tetrominoe.NoShape:
return False
self.curPiece = newPiece
self.curX = newX
self.curY = newY
self.update()
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])
painter.fillRect(x + 1, y + 1, self.squareWidth() - 2,
self.squareHeight() - 2, color)
painter.setPen(color.lighter())
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)
class Tetrominoe:
NoShape = 0
ZShape = 1
SShape = 2
LineShape = 3
TShape = 4
SquareShape = 5
LShape = 6
MirroredLShape = 7
class Shape:
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)]
self.pieceShape = Tetrominoe.NoShape
self.setShape(Tetrominoe.NoShape)
def shape(self):
"""returns shape"""
return self.pieceShape
def setShape(self, shape):
"""sets a shape"""
table = Shape.coordsTable[shape]
for i in range(4):
for j in range(2):
self.coords[i][j] = table[i][j]
self.pieceShape = shape
def setRandomShape(self):
"""chooses a random shape"""
self.setShape(random.randint(1, 7))
def x(self, index):
"""returns x coordinate"""
return self.coords[index][0]
def y(self, index):
"""returns y coordinate"""
return self.coords[index][1]
def setX(self, index, x):
"""sets x coordinate"""
self.coords[index][0] = x
def setY(self, index, y):
"""sets y coordinate"""
self.coords[index][1] = y
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
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
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
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
def rotateLeft(self):
"""rotates shape to the left"""
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
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
def main():
app = QApplication([])
tetris = Tetris()
sys.exit(app.exec())
if __name__ == '__main__':
main()
The game has been simplified a bit to make it easier to understand. Immediately after the game starts. We can press P
the key to pause the game. Press the space bar and the block will immediately drop to the bottom. The game runs at a fixed speed without any acceleration. The score is the number of rows eliminated.
self.tboard = Board(self)
self.setCentralWidget(self.tboard)
Instantiate the Board class and set it as the central widget of the application.
self.statusbar = self.statusBar()
self.tboard.msg2Statusbar[str].connect(self.statusbar.showMessage)
Here a status bar is created for displaying messages. We'll display three possible messages: the number of lines deleted, a pause message, or a game over message. msg2Statusbar
is a custom signal implemented in the Board class. showMessage
is a built-in method for displaying messages on the status bar.
self.tboard.start()
Initialize the game.
class Board(QFrame):
msg2Statusbar = pyqtSignal(str)
...
pyqtSignal
Created a custom signal. If you want to display information or scores on the status bar, msg2Statusbar
just trigger the signal.
BoardWidth = 10
BoardHeight = 22
Speed = 300
These are Board
the parameters of the . BoardWidth
and BoardHeight
define the width and height of the artboard. Speed
is the speed of the game, every 300 milliseconds the game loop progresses.
...
self.curX = 0
self.curY = 0
self.numLinesRemoved = 0
self.board = []
...
In initBoard
the method, we initialize some variables. self.board
It is a list of numbers from 0 to 7, representing various shapes and position information of bricks.
def shapeAt(self, x, y):
"""determines shape at the board position"""
return self.board[(y * Board.BoardWidth) + x]
shapeAt
method determines the position of the shape.
def squareWidth(self):
"""returns the width of one square"""
return self.contentsRect().width() // Board.BoardWidth
Artboards can be dynamically resized. squareWidth
Computes and returns the pixel width of a single shape. Board.BoardWidth
is the size of the artboard.
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()
pause
method pauses the game, stops the timer and displays a message on the status bar.
def paintEvent(self, event):
"""paints all shapes of the game"""
painter = QPainter(self)
rect = self.contentsRect()
...
paintEvent
method to draw the game. QPainter
It is the method of performing low-level drawing in PyQt6.
for i in range(Board.BoardHeight):
for j in range(Board.BoardWidth):
shape = self.shapeAt(j, Board.BoardHeight - i - 1)
if shape != Tetrominoe.NoShape:
self.drawSquare(painter,
rect.left() + j * self.squareWidth(),
boardTop + i * self.squareHeight(), shape)
Game drawing is divided into two steps. In the first step, we draw all the shapes, or the ones that have landed on the bottom of the artboard. All squares are recorded in self.board
the variable list of . This variable can be accessed using shapeAt
the method.
if self.curPiece.shape() != Tetrominoe.NoShape:
for i in range(4):
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())
The second step is to draw the falling bricks.
elif key == Qt.Key.Key_Right.value:
self.tryMove(self.curPiece, self.curX + 1, self.curY)
In keyPressEvent
the method, we check the pressed key. If the right arrow key is pressed, an attempt will be made to move the part to the right. Says "try" because it probably won't move.
elif key == Qt.Key.Key_Up.value:
self.tryMove(self.curPiece.rotateLeft(), self.curX, self.curY)
Up
key to rotate the brick left.
elif key == Qt.Key.Key_Space.value:
self.dropDown()
Space
The key will cause the bricks to fall straight to the bottom.
elif key == Qt.Key.Key_D.value:
self.oneLineDown()
Pressing the D key will speed up the falling of the bricks for a while.
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)
In the timer event, either create a new one after the previous one has reached the bottom, or move one brick down one row.
def clearBoard(self):
"""clears shapes from the board"""
for i in range(Board.BoardHeight * Board.BoardWidth):
self.board.append(Tetrominoe.NoShape)
clearBoard
The method uses the setting Tetrominoe.NoShape
method to clear all the bricks on the drawing board.
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):
if not self.shapeAt(j, i) == Tetrominoe.NoShape:
n = n + 1
if n == 10:
rowsToRemove.append(i)
rowsToRemove.reverse()
for m in rowsToRemove:
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)
...
If the brick falls to the bottom, call removeFullLines
the method, find all complete rows and delete them. Move all lines to the position of the current entire line to achieve the effect of deletion. Note that we reversed the order of the lines to be deleted, otherwise there will be a BUG, in our case we used naive gravity
, which means that bricks that are not entire lines may float above the empty gaps.
def newPiece(self):
"""creates a new shape"""
self.curPiece = 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
self.msg2Statusbar.emit("Game over")
newPiece
The method creates random bricks, if the created bricks cannot reach the initial position, the game ends.
def tryMove(self, newPiece, newX, newY):
"""tries to move a shape"""
for i in range(4):
x = newX + newPiece.x(i)
y = newY - newPiece.y(i)
if x < 0 or x >= Board.BoardWidth or y < 0 or y >= Board.BoardHeight:
return False
if self.shapeAt(x, y) != Tetrominoe.NoShape:
return False
self.curPiece = newPiece
self.curX = newX
self.curY = newY
self.update()
return True
tryMove
The method attempts to move a brick, and returns if the brick is on the edge of the artboard or another brick False
. Otherwise the move is performed.
class Tetrominoe:
NoShape = 0
ZShape = 1
SShape = 2
LineShape = 3
TShape = 4
SquareShape = 5
LShape = 6
MirroredLShape = 7
Tetrominoe
The class contains the names of all the shapes, here there is also an NoShape
empty shape called .
Shape
The class holds information about bricks.
class Shape(object):
coordsTable = (
((0, 0), (0, 0), (0, 0), (0, 0)),
((0, -1), (0, 0), (-1, 0), (-1, 1)),
...
)
...
coordsTable
The tuple includes the combined coordinates of all bricks, and the required bricks can be spelled out from this template.
self.coords = [[0,0] for i in range(4)]
At initialization, an empty coordinate list is created to store the coordinates of the bricks.
Graphic: Coordinates
The above picture can help us better understand the meaning of coordinate values. For example, (0, -1), (0, 0), (-1, 0), (-1, -1)
a brick representing a zigzag, which is the shape in the illustration.
def rotateLeft(self):
"""rotates shape to the left"""
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
rotateLeft
Method to rotate the brick to the left. The square does not need to be rotated, so it returns the current object directly. When the brick is rotated, a new object will be generated to represent the rotated brick.
Graphic: Tetris