Home  Contents

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.

Tetrominos

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.


#!/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.

Koordinaten

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.

Tetris
Abbildung: Tetris