I've made my first turn-based game in PyQt5. I suppose that its idea can also be used by other novice GUI programmers.
There is a 5 by 5 squared unpainted field and 4 players. Each player starts at corner square and has his own colour. Player can move to adjacent square and fill it with his colour if it isn’t occupied by other player or isn’t filled in player's colour.
If player has nowhere to move he is randomly teleported to non-filled square. Game ends when all squares are filled. Player who has most squares filled with his colour wins.
Also I would like to hear any suggestions about code improvement. The problem parts are probably self.turn() and self.turn_loop() in MyApp class since they have a bit complicated "if/elif/else" logic.
Main module (PainterField.py):
#!/usr/bin/env python
'''
Game name: PainterFiled
Author: Igor Vasylchenko
As this module and its sub-modules use PyQt5 they are distributed and
may be used under the terms of the GNU General Public License version 3.0.
See http://www.gnu.org/copyleft/gpl.html
'''
import random
import sys
import traceback
from PyQt5.QtCore import (QRectF, QTimer, Qt)
from PyQt5.QtGui import (QBrush, QColor, QImage)
from PyQt5.QtWidgets import (QApplication, QGraphicsItem, QGraphicsScene,
QGraphicsView, QMainWindow, QPushButton)
# Custom classes
from FieldClasses import (PlayerQGraphics, SquareQGrapics)
from FieldFunctions import (create_obstacles, create_squares,
create_players, print_main, print_rules)
# Gui generated by Qt5
from gui import Ui_Field as Ui_MainWindow
'''Exceptions hadling block. Needed to track errors during
operation.'''
sys._excepthook = sys.excepthook
def exception_hook(exctype, value, traceback):
sys._excepthook(exctype, value, traceback)
sys.exit(1)
sys.excepthook = exception_hook
class MyApp(QMainWindow, Ui_MainWindow):
def __init__(self):
QMainWindow.__init__(self)
Ui_MainWindow.__init__(self)
self.setupUi(self)
# Important property that deletes all widgets (including QTimer)
# on close.
self.setAttribute(Qt.WA_DeleteOnClose)
# Variables
self.key = None
self.players = create_players()
self.scene = QGraphicsScene()
self.squares = create_squares(9, 9)
'''Timer is used to repeat self.turn_loop and hadle turn sequence.
It is stopped by default and starts after self.start() is called.
Stops when current game ends or apllication closes (see note above).'''
self.timer = QTimer(self)
self.draw_field(self.squares, self.players)
self.QGraph_field.setScene(self.scene)
# Connecting signals to slots
self.QBut_main.clicked.connect(lambda: self.print_text(print_main()))
self.QBut_reset.clicked.connect(self.reset)
self.QBut_rules.clicked.connect(lambda: self.print_text(print_rules()))
self.QBut_start.clicked.connect(self.start)
self.timer.timeout.connect(self.turn_loop)
def draw_field(self, squares, players):
for xy in squares.keys():
self.scene.addItem(squares[xy])
for ID in players.keys():
player = players[ID]
self.scene.addItem(player)
self.squares[player.xy].fill(player.colour)
def isEnd(self):
for square in self.squares.values():
if square.colour == 'cyan':
end = 0
break
else:
end = 1
return end
def keyPressEvent(self, event):
key = event.key()
# Player movement
if key in (Qt.Key_Left, Qt.Key_Right,
Qt.Key_Up, Qt.Key_Down):
self.key = key
# Start new game
elif key in (Qt.Key_Enter, Qt.Key_Return):
self.start()
elif key == Qt.Key_Escape:
self.close()
# Reset current field and draw new one.
# Starting the game is still needed.
elif key == Qt.Key_R:
self.reset()
def print_text(self, source):
self.QText_status.setHtml(source)
def reset(self):
self.timer.stop()
self.key = None
self.players = create_players()
self.squares = create_squares(9, 9)
self.scene.clear()
self.draw_field(self.squares, self.players)
self.print_text(print_main())
def results(self):
results = ()
text = ''
for ID in self.players:
player = self.players[ID]
colour = player.colour
score = 0
for square in self.squares.values():
if colour == square.colour:
score += 1
results += (score, )
score = '<p>Player {0} ({1}): {2}</p>'.format(ID, player.colour,
score)
text += score
max_score = max(results)
text += 'Player {} won with score of {}!'.format(
results.index(max_score),
max_score
)
text = text.replace('Player 0', 'You')
# Tie between player and computer is still considered a win)))
return text
def start(self):
self.key = None
if not self.isEnd():
self.timer.start(50)
# Initially was divided in two functions turn_pl and turn_ai,
# but they shared a lot of code. Still a bit messy though.
def turn(self, ID):
'''If there is no room to move player is teleported on
unpainted square.'''
player = self.players[ID]
obstacles = create_obstacles(self.players)
free_directions = player.findDirections(self.squares, obstacles)
# Setting parameters to teleport
if not free_directions:
for xy in self.squares.keys():
square = self.squares[xy]
if square.colour == 'cyan':
player.xy = xy
player.update()
square.fill(player.colour)
break
# Returns 1 so next ai player can make move
return 1
# For human player pressed key is used for player movement
elif ID == 0:
key = self.key
# Ai moves in random direction
else:
direction = random.sample(free_directions, 1)
key = direction[0]
# Moving player in designated direction
obstacles = create_obstacles(self.players)
xy = player.goto(key, obstacles, self.squares)
if xy is not None:
self.squares[xy].fill(player.colour)
return xy
def turn_loop(self):
end = self.isEnd()
if not end:
# Player turn after arrow key is pressed
if self.key is not None:
xy = self.turn(0)
self.key = None
# Waiting on player turn
else:
xy = None
# Computer ('ai') turn
if xy is not None:
for ID in range(1, len(self.players)):
self.turn(ID)
# Ending game and printing results
else:
self.timer.stop()
self.print_text(self.results())
if __name__ == "__main__":
app = QApplication(sys.argv)
window = MyApp()
window.show()
sys.exit(app.exec_())
Custom classes for players and squares (FieldClasses.py):
from PyQt5.QtCore import (QRectF, Qt)
from PyQt5.QtGui import (QBrush, QColor, QImage)
from PyQt5.QtWidgets import QGraphicsItem
class PlayerQGraphics(QGraphicsItem):
def __init__(self, xy=(-1,-1),
colour='green', icon='graphics/player.png'):
QGraphicsItem.__init__(self)
self.colour = colour
self.icon = icon
self.xy = xy
def boundingRect(self): #Is set to field dimensions
return QRectF(0,0,270,270)
def findDirections(self, squares, obstacles, exceptColours=None):
'''Returns list of free directions to move.
Direction is not free if colour of target square is similar
to player’s or target square is already occupied.'''
if exceptColours is None:
exceptColours = (self.colour, )
obstacles = tuple(obstacles)
free_directions = []
for n in range(1, 5):
xy = self.prepareGoto(n, exceptColours, obstacles, squares)
if xy is not None:
free_directions.append(n)
return free_directions
def goto(self, direction, obstacles, squares, exceptColours=None):
'''Checks direction by self.prepareGoto(...) and moves
player if direction is free.'''
if exceptColours is None:
exceptColours = (self.colour, )
xy = self.prepareGoto(direction, exceptColours, obstacles, squares)
if xy is not None:
self.xy = xy
self.update()
return xy
def paint(self, painter, option, widget):
x, y = self.xy
target = QRectF(x*30, y*30, 28, 28)
source = QRectF(0, 0, 28, 28)
painter.drawImage(target, QImage(self.icon), source)
def prepareGoto(self, direction, exceptColours, obstacles, squares):
'''Checks if selected direction is free and returns actual
coordinates to move is so. Otherwise returns None'''
x, y = self.xy
if direction in (Qt.Key_Up, 'u', 1):
y = y - 1
elif direction in (Qt.Key_Down, 'd', 2):
y = y + 1
elif direction in (Qt.Key_Left, 'l', 3):
x = x - 1
elif direction in (Qt.Key_Right, 'r', 4):
x = x + 1
xy = (x, y)
try:
for n in obstacles:
if xy == n:
xy = None
break
if (xy is not None
and squares[xy].colour in exceptColours):
xy = None
except KeyError:
xy = None
return xy
class SquareQGrapics(QGraphicsItem):
def __init__(self, xy=(-1,-1), colour='cyan'):
QGraphicsItem.__init__(self)
self.colour = colour
self.xy = xy
def boundingRect(self):
x, y = self.xy
return QRectF(x*30, y*30, 28, 28)
def fill(self, new_colour='red'):
'''Fills square with selected colour by updating self.colour.'''
self.colour = new_colour
self.update()
def paint(self, painter, option, widget):
x, y = self.xy
colour = QBrush(QColor(self.colour))
painter.setBrush(colour)
painter.drawRect(x*30, y*30, 28, 28)
Custom functions (FieldFunctions.py):
#Custom classes
from FieldClasses import (PlayerQGraphics, SquareQGrapics)
#Obstacles are players' coordinates. And it is easier to return them as
#generator since they need update already when called.
def create_obstacles(players):
for player in players.values():
yield player.xy
def create_squares(cols, rows):
squares = {}
for x in range(cols):
for y in range(rows):
squares[(x,y)] = SquareQGrapics((x,y))
return squares
def create_players():
players = {}
ai = 'graphics/ai.png'
players[0] = PlayerQGraphics(xy=(0,0)) # Human controlled player
players[1] = PlayerQGraphics(xy=(8,0), colour='red', icon=ai)
players[2] = PlayerQGraphics(xy=(0,8), colour='blue', icon=ai)
players[3] = PlayerQGraphics(xy=(8,8), colour='black', icon=ai, )
return players
def print_main():
text = ('''<p>Welcome to PainterField!</p>
<p>To start game press "Start" or "Enter"
key. Use arrow keys to move player icon from the top left
corner.</p>
<p>Click "Main menu" to read this message.</p>
<p>Click "Rules" to read them.</p>
<p>Press "Reset" or "R" key to reset game
field (arrow keys will be frozen).</p>''')
return text
def print_rules():
text = ('''<p>Each player goes to one of four adjacent squares
and fills it with his colour. Squares of player’s
colour are not allowed to enter.</p>
<p>If player has nowhere to move he is randomly teleported
to an empty square.</p>
<p>Game ends when all squares are painted. Player,
who painted the most squares, wins.</p>''')
return text
Player icons (rename to ai.png and player.png and place in '/graphics'):
GUI code generated from .ui file (gui.py):
# -*- coding: utf-8 -*-
# Form implementation generated from reading ui file 'PainterField.ui'
#
# Created by: PyQt5 UI code generator 5.7
#
# WARNING! All changes made in this file will be lost!
from PyQt5 import QtCore, QtGui, QtWidgets
class Ui_Field(object):
def setupUi(self, Field):
Field.setObjectName("Field")
Field.resize(270, 570)
Field.setMinimumSize(QtCore.QSize(270, 570))
Field.setMaximumSize(QtCore.QSize(270, 570))
self.QGraph_field = QtWidgets.QGraphicsView(Field)
self.QGraph_field.setGeometry(QtCore.QRect(0, 300, 270, 270))
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.QGraph_field.sizePolicy().hasHeightForWidth())
self.QGraph_field.setSizePolicy(sizePolicy)
self.QGraph_field.setMinimumSize(QtCore.QSize(270, 270))
self.QGraph_field.setMaximumSize(QtCore.QSize(270, 270))
self.QGraph_field.setFocusPolicy(QtCore.Qt.NoFocus)
self.QGraph_field.setFrameShape(QtWidgets.QFrame.NoFrame)
self.QGraph_field.setLineWidth(0)
self.QGraph_field.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
self.QGraph_field.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
self.QGraph_field.setSizeAdjustPolicy(QtWidgets.QAbstractScrollArea.AdjustIgnored)
self.QGraph_field.setSceneRect(QtCore.QRectF(0.0, 0.0, 270.0, 270.0))
self.QGraph_field.setAlignment(QtCore.Qt.AlignLeading|QtCore.Qt.AlignLeft|QtCore.Qt.AlignTop)
self.QGraph_field.setObjectName("QGraph_field")
self.verticalLayoutWidget = QtWidgets.QWidget(Field)
self.verticalLayoutWidget.setGeometry(QtCore.QRect(10, 10, 251, 271))
self.verticalLayoutWidget.setObjectName("verticalLayoutWidget")
self.verticalLayout = QtWidgets.QVBoxLayout(self.verticalLayoutWidget)
self.verticalLayout.setContentsMargins(0, 0, 0, 0)
self.verticalLayout.setObjectName("verticalLayout")
self.QText_status = QtWidgets.QTextBrowser(self.verticalLayoutWidget)
font = QtGui.QFont()
font.setPointSize(10)
self.QText_status.setFont(font)
self.QText_status.setFocusPolicy(QtCore.Qt.NoFocus)
self.QText_status.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
self.QText_status.setReadOnly(True)
self.QText_status.setObjectName("QText_status")
self.verticalLayout.addWidget(self.QText_status)
self.horizontalLayout_4 = QtWidgets.QHBoxLayout()
self.horizontalLayout_4.setObjectName("horizontalLayout_4")
self.QBut_start = QtWidgets.QPushButton(self.verticalLayoutWidget)
font = QtGui.QFont()
font.setPointSize(14)
self.QBut_start.setFont(font)
self.QBut_start.setFocusPolicy(QtCore.Qt.NoFocus)
self.QBut_start.setObjectName("QBut_start")
self.horizontalLayout_4.addWidget(self.QBut_start)
self.QBut_rules = QtWidgets.QPushButton(self.verticalLayoutWidget)
font = QtGui.QFont()
font.setPointSize(14)
self.QBut_rules.setFont(font)
self.QBut_rules.setFocusPolicy(QtCore.Qt.NoFocus)
self.QBut_rules.setObjectName("QBut_rules")
self.horizontalLayout_4.addWidget(self.QBut_rules)
self.verticalLayout.addLayout(self.horizontalLayout_4)
self.horizontalLayout_3 = QtWidgets.QHBoxLayout()
self.horizontalLayout_3.setObjectName("horizontalLayout_3")
self.QBut_reset = QtWidgets.QPushButton(self.verticalLayoutWidget)
font = QtGui.QFont()
font.setPointSize(14)
self.QBut_reset.setFont(font)
self.QBut_reset.setFocusPolicy(QtCore.Qt.NoFocus)
self.QBut_reset.setObjectName("QBut_reset")
self.horizontalLayout_3.addWidget(self.QBut_reset)
self.QBut_main = QtWidgets.QPushButton(self.verticalLayoutWidget)
font = QtGui.QFont()
font.setPointSize(14)
self.QBut_main.setFont(font)
self.QBut_main.setFocusPolicy(QtCore.Qt.NoFocus)
self.QBut_main.setObjectName("QBut_main")
self.horizontalLayout_3.addWidget(self.QBut_main)
self.verticalLayout.addLayout(self.horizontalLayout_3)
self.retranslateUi(Field)
QtCore.QMetaObject.connectSlotsByName(Field)
def retranslateUi(self, Field):
_translate = QtCore.QCoreApplication.translate
Field.setWindowTitle(_translate("Field", "PainterField"))
self.QText_status.setHtml(_translate("Field", "<!DOCTYPE HTML PUBLIC \"-//W3C//DTD HTML 4.0//EN\" \"http://www.w3.org/TR/REC-html40/strict.dtd\">\n"
"<html><head><meta name=\"qrichtext\" content=\"1\" /><style type=\"text/css\">\n"
"p, li { white-space: pre-wrap; }\n"
"</style></head><body style=\" font-family:\'MS Shell Dlg 2\'; font-size:10pt; font-weight:400; font-style:normal;\">\n"
"<p style=\" margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;\">Welcome to PainterField! </p>\n"
"<p style=\" margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;\">To start game press "Start" or "Enter" key. Use arrow keys to move player icon at top left corner. </p>\n"
"<p style=\" margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;\">Click "Main menu" to read this message. </p>\n"
"<p style=\" margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;\">Click "Rules" to read them. </p>\n"
"<p style=\" margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;\">Press "Reset" or "R" key to reset game field (arrow keys will be frozen).</p></body></html>"))
self.QBut_start.setText(_translate("Field", "Start"))
self.QBut_rules.setText(_translate("Field", "Rules"))
self.QBut_reset.setText(_translate("Field", "Reset"))
self.QBut_main.setText(_translate("Field", "Main menu"))
Also see my answer below for further improvements of coding style/readability.
Follow-up question on this post: Example of PyQt5 Snake game