Ein Tetrisspiel in PyQt4
Ein Computerspiel zu schreiben ist eine große Herausforderung. Früher oder später will ein Programmierer ein Computerspiel entwickeln. In der Tat kommen viele Menschen überhaupt erst zum Programmieren, weil sie Spiele gespielt haben und schließlich selbst mal eins entwickeln möchten. Die Entwicklung eines Computerspiels wird Ihnen sehr dabei helfen, Ihre Programmierfertigkeiten zu verbessern.
Tetris
Tetris ist eines der populärsten Computerspiele, das jemals geschrieben wurde. Das Original wurde 1985 von dem russischen Programmierer Alexey Pajitnov entworfen und programmiert. Seit damals ist Tetris auf fast jeder Computer-Plattform und in etlichen Variationen verfügbar. Selbst auf meinem Mobil-Telefon befindet sich eine modifizierte Version von Tetris.
Tetris ist ein Falling-Block-Puzzle-Spiel, bei dem es sieben unterschiedliche Figuren namens Tetrominos gibt: S-Form, Z-Form, T-Form, L-Form, Linien-Form, Spiegel-L-Form und eine Quadrat-Form. Jede dieser Formen wird aus vier Quadraten zusammengesetzt. Die Formen fallen abwärts das Spielfeld herab. Inhalt des Tetris-Spiels ist das Drehen und Bewegen der Formen, sodass sie so gut wie möglich ineinander passen. Wenn es gelingt, eine Reihe zu bilden, wird diese zerstört und wir erhalten dafür Punkte. Das Spiel endet, wenn der obere Rand des Spielfelds erreicht wurde.
Abbildung: Tetrominos
PyQt4 ist ein Toolkit für das Erstellen von Anwendungen. Es gibt andere Bibliotheken, die speziell auf die Entwicklung von Computerspielen ausgerichtet sind. Nichts desto trotz können PyQt4 und andere Anwendungs-Toolkits für das Erstellen von Spielen eingesetzt werden.
Das folgende Beispiel ist eine modifizierte Version von Tetris inklusive der PyQt4-Installationsdateien.
Die Entwicklung
Da wir keine Bilder für unser Tetris haben, zeichnen wir die Tetrominos mit Hilfe der im PyQt-Toolkit verfügbaren Zeichen-API. Hinter jedem Computerspiel steht ein mathematisches Modell - so auch in Tetris.
Einige Gedanken zum Spiel.
- Wir verwenden QtCore.QBasicTimer(), um einen Spielzyklus zu erstellen
- Die Tetrominos werden gezeichnet
- Die Formen bewegen sich von Quadrat zu Quadrat, nicht Pixel für Pixel.
- Mathematisch betrachtet ist das Spielbrett lediglich eine Liste von Ziffern.
#!/usr/bin/python
# tetris.py
import sys
import random
from PyQt4 import QtCore, QtGui
class Tetris(QtGui.QMainWindow):
def __init__(self):
QtGui.QMainWindow.__init__(self)
self.setGeometry(300, 300, 180, 380)
self.setWindowTitle('Tetris')
self.tetrisboard = Board(self)
self.setCentralWidget(self.tetrisboard)
self.statusbar = self.statusBar()
self.connect(self.tetrisboard, QtCore.SIGNAL("messageToStatusbar(QString)"),
self.statusbar, QtCore.SLOT("showMessage(QString)"))
self.tetrisboard.start()
self.center()
def center(self):
screen = QtGui.QDesktopWidget().screenGeometry()
size = self.geometry()
self.move((screen.width()-size.width())/2,
(screen.height()-size.height())/2)
class Board(QtGui.QFrame):
BoardWidth = 10
BoardHeight = 22
Speed = 300
def __init__(self, parent):
QtGui.QFrame.__init__(self, parent)
self.timer = QtCore.QBasicTimer()
self.isWaitingAfterLine = False
self.curPiece = Shape()
self.nextPiece = Shape()
self.curX = 0
self.curY = 0
self.numLinesRemoved = 0
self.board = []
self.setFocusPolicy(QtCore.Qt.StrongFocus)
self.isStarted = False
self.isPaused = False
self.clearBoard()
self.nextPiece.setRandomShape()
def shapeAt(self, x, y):
return self.board[(y * Board.BoardWidth) + x]
def setShapeAt(self, x, y, shape):
self.board[(y * Board.BoardWidth) + x] = shape
def squareWidth(self):
return self.contentsRect().width() / Board.BoardWidth
def squareHeight(self):
return self.contentsRect().height() / Board.BoardHeight
def start(self):
if self.isPaused:
return
self.isStarted = True
self.isWaitingAfterLine = False
self.numLinesRemoved = 0
self.clearBoard()
self.emit(QtCore.SIGNAL("messageToStatusbar(QString)"),
str(self.numLinesRemoved))
self.newPiece()
self.timer.start(Board.Speed, self)
def pause(self):
if not self.isStarted:
return
self.isPaused = not self.isPaused
if self.isPaused:
self.timer.stop()
self.emit(QtCore.SIGNAL("messageToStatusbar(QString)"), "paused")
else:
self.timer.start(Board.Speed, self)
self.emit(QtCore.SIGNAL("messageToStatusbar(QString)"),
str(self.numLinesRemoved))
self.update()
def paintEvent(self, event):
painter = QtGui.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 != Tetrominoes.NoShape:
self.drawSquare(painter,
rect.left() + j * self.squareWidth(),
boardTop + i * self.squareHeight(), shape)
if self.curPiece.shape() != Tetrominoes.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):
if not self.isStarted or self.curPiece.shape() == Tetrominoes.NoShape:
QtGui.QWidget.keyPressEvent(self, event)
return
key = event.key()
if key == QtCore.Qt.Key_P:
self.pause()
return
if self.isPaused:
return
elif key == QtCore.Qt.Key_Left:
self.tryMove(self.curPiece, self.curX - 1, self.curY)
elif key == QtCore.Qt.Key_Right:
self.tryMove(self.curPiece, self.curX + 1, self.curY)
elif key == QtCore.Qt.Key_Down:
self.tryMove(self.curPiece.rotatedRight(), self.curX, self.curY)
elif key == QtCore.Qt.Key_Up:
self.tryMove(self.curPiece.rotatedLeft(), self.curX, self.curY)
elif key == QtCore.Qt.Key_Space:
self.dropDown()
elif key == QtCore.Qt.Key_D:
self.oneLineDown()
else:
QtGui.QWidget.keyPressEvent(self, event)
def timerEvent(self, event):
if event.timerId() == self.timer.timerId():
if self.isWaitingAfterLine:
self.isWaitingAfterLine = False
self.newPiece()
else:
self.oneLineDown()
else:
QtGui.QFrame.timerEvent(self, event)
def clearBoard(self):
for i in range(Board.BoardHeight * Board.BoardWidth):
self.board.append(Tetrominoes.NoShape)
def dropDown(self):
newY = self.curY
while newY > 0:
if not self.tryMove(self.curPiece, self.curX, newY - 1):
break
newY -= 1
self.pieceDropped()
def oneLineDown(self):
if not self.tryMove(self.curPiece, self.curX, self.curY - 1):
self.pieceDropped()
def pieceDropped(self):
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):
numFullLines = 0
rowsToRemove = []
for i in range(Board.BoardHeight):
n = 0
for j in range(Board.BoardWidth):
if not self.shapeAt(j, i) == Tetrominoes.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.emit(QtCore.SIGNAL("messageToStatusbar(QString)"),
str(self.numLinesRemoved))
self.isWaitingAfterLine = True
self.curPiece.setShape(Tetrominoes.NoShape)
self.update()
def newPiece(self):
self.curPiece = self.nextPiece
self.nextPiece.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(Tetrominoes.NoShape)
self.timer.stop()
self.isStarted = False
self.emit(QtCore.SIGNAL("messageToStatusbar(QString)"), "Game over")
def tryMove(self, newPiece, newX, newY):
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) != Tetrominoes.NoShape:
return False
self.curPiece = newPiece
self.curX = newX
self.curY = newY
self.update()
return True
def drawSquare(self, painter, x, y, shape):
colorTable = [0x000000, 0xCC6666, 0x66CC66, 0x6666CC,
0xCCCC66, 0xCC66CC, 0x66CCCC, 0xDAAA00]
color = QtGui.QColor(colorTable[shape])
painter.fillRect(x + 1, y + 1, self.squareWidth() - 2,
self.squareHeight() - 2, color)
painter.setPen(color.light())
painter.drawLine(x, y + self.squareHeight() - 1, x, y)
painter.drawLine(x, y, x + self.squareWidth() - 1, y)
painter.setPen(color.dark())
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 Tetrominoes(object):
NoShape = 0
ZShape = 1
SShape = 2
LineShape = 3
TShape = 4
SquareShape = 5
LShape = 6
MirroredLShape = 7
class Shape(object):
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 = Tetrominoes.NoShape
self.setShape(Tetrominoes.NoShape)
def shape(self):
return self.pieceShape
def setShape(self, 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):
self.setShape(random.randint(1, 7))
def x(self, index):
return self.coords[index][0]
def y(self, index):
return self.coords[index][1]
def setX(self, index, x):
self.coords[index][0] = x
def setY(self, index, y):
self.coords[index][1] = y
def minX(self):
m = self.coords[0][0]
for i in range(4):
m = min(m, self.coords[i][0])
return m
def maxX(self):
m = self.coords[0][0]
for i in range(4):
m = max(m, self.coords[i][0])
return m
def minY(self):
m = self.coords[0][1]
for i in range(4):
m = min(m, self.coords[i][1])
return m
def maxY(self):
m = self.coords[0][1]
for i in range(4):
m = max(m, self.coords[i][1])
return m
def rotatedLeft(self):
if self.pieceShape == Tetrominoes.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 rotatedRight(self):
if self.pieceShape == Tetrominoes.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
app = QtGui.QApplication(sys.argv)
tetris = Tetris()
tetris.show()
sys.exit(app.exec_())
Ich habe das Spiel ein wenig vereinfacht, sodass es leichter zu verstehen ist. Das Spiel beginnt sofort, nachdem es gestartet wurde. Wir können das Spiel pausieren, indem wir die P-Taste drücken. Die Leertaste lässt die Tetrominos unmittelbar zu Boden fallen. Das Spiel verläuft in konstanter Geschwindigkeit, Beschleunigung wurde nicht eingebaut. Die Punktzahl besteht aus der Anzahl der Reihen, die wir entfernt haben.
self.statusbar = self.statusBar()
self.connect(self.tetrisboard, QtCore.SIGNAL("messageToStatusbar(QString)"),
self.statusbar, QtCore.SLOT("showMessage(QString)"))
Wir erzeugen eine Statusleiste, in der wir drei mögliche Nachrichten anzeigen lassen: Die Zahl der bereits entfernten Reihen, die Pause-Nachricht und die Game-Over-Nachricht.
...
self.curX = 0
self.curY = 0
self.numLinesRemoved = 0
self.board = []
...
Bevor wir einen Spielzyklus starten, initialisieren wir einige wichtige Variablen. Die self.board-Variable ist eine Ziffernliste von 0 bis 7. Sie repräsentiert die Position verschiedener Figuren und Überbleibsel der Formen auf dem Spielbrett.
for j in range(Board.BoardWidth):
shape = self.shapeAt(j, Board.BoardHeight - i - 1)
if shape != Tetrominoes.NoShape:
self.drawSquare(painter,
rect.left() + j * self.squareWidth(),
boardTop + i * self.squareHeight(), shape)
Die grafische Darstellung des Spiels wird in zwei Schritte unterteilt: Im ersten zeichnen wir die Figuren oder deren verbliebene Reste, die bereits auf den Boden des Spielbretts gesunken sind. Die Quadrate werden in der self.board-Liste gespeichert. Wir greifen darauf mit der shapeAt()-Methode zu.
if self.curPiece.shape() != Tetrominoes.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())
Der nächste Schritt ist das Zeichnen des im Moment herunterfallenden Stücks.
elif key == QtCore.Qt.Key_Left:
self.tryMove(self.curPiece, self.curX - 1, self.curY)
elif key == QtCore.Qt.Key_Right:
self.tryMove(self.curPiece, self.curX + 1, self.curY)
Mit dem keyPressEvent überwachen wir das Drücken von Tasten.
Wenn wird die rechte Pfeiltaste drücken, versuchen wir, das Teil nach
rechts zu verschieben. Versuchen
, weil es durchaus möglich ist,
dass sich das Teil nicht bewegen lässt.
def tryMove(self, newPiece, newX, newY):
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) != Tetrominoes.NoShape:
return False
self.curPiece = newPiece
self.curX = newX
self.curY = newY
self.update()
return True
Mit der tryMove()-Methode versuchen wir unsere Figuren zu bewegen. Wenn die Figur sich am Rand des Boards befindet oder an ein anderes Teil angrenzt, gibt die Methode false zurück. Andernfalls platzieren wir das aktuell sinkende Teil auf eine neue Position.
def timerEvent(self, event):
if event.timerId() == self.timer.timerId():
if self.isWaitingAfterLine:
self.isWaitingAfterLine = False
self.newPiece()
else:
self.oneLineDown()
else:
QtGui.QFrame.timerEvent(self, event)
Mit dem Timer-Ereignis erzeugen wir ein neues Teil, nachdem das vorangegangene den Boden erreicht hat oder wir bewegen ein sinkendes Teil eine Zeile tiefer.
def removeFullLines(self):
numFullLines = 0
rowsToRemove = []
for i in range(Board.BoardHeight):
n = 0
for j in range(Board.BoardWidth):
if not self.shapeAt(j, i) == Tetrominoes.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))
...
Wenn das Teil den Boden erreicht, rufen wir die removeFullLines()-Methode auf. Zunächst finden wir alle vollständigen Zeilen heraus und entfernen sie, indem wir alle Zeilen über der im Moment vollen, zu entfernenden eine Zeile herab. Beachten Sie, dass wir die Reihenfolge der zu entfernenden Zeilen umdrehen, andernfalls würde es nicht richtig funktionieren. In unserem Fall verwenden wir naive Schwerkraft, d.h., dass die Teile über leere Lücken gleiten.
def newPiece(self):
self.curPiece = self.nextPiece
self.nextPiece.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(Tetrominoes.NoShape)
self.timer.stop()
self.isStarted = False
self.emit(QtCore.SIGNAL("messageToStatusbar(QString)"), "Game over")
Die newPiece()-Methode erzeugt zufällig ein neues Tetris-Stück. Wenn das Stück nicht in seine anfängliche Position kann, ist das Spiel vorbei.
Die Shape-Klasse speichert Informationen über das Tetris-Stück.
self.coords = [[0,0] for i in range(4)]
Bei der Erstellung erzeugen wir eine leere Koordinatenliste. Die Liste wird die Koordinaten der Tetris-Stücke speichern. Zum Beispiel repräsentieren die Tupel (0,-1), (0,0), (0,1), (1,1) eine rotierte S-Form. Das folgende Diagramm illustriert ihr Aussehen.
Abbildung: Koordinaten
Wenn wir das aktuell sinkende Stück zeichnen, zeichnen wir es an der Position self.curX> und self.curY. Dann werfen wir einen Blick auf die Koordinaten in der Tabelle und zeichnen alle vier Quadrate.