7
\$\begingroup\$

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 &quot;Start&quot; or &quot;Enter&quot;
            key. Use arrow keys to move player icon from the top left
            corner.</p>

            <p>Click &quot;Main menu&quot; to read this message.</p>

            <p>Click &quot;Rules&quot; to read them.</p>

            <p>Press &quot;Reset&quot; or &quot;R&quot; 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'): place in 'graphics/ai.png' place in 'graphics/ai.png'

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 &quot;Start&quot; or &quot;Enter&quot; 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 &quot;Main menu&quot; 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 &quot;Rules&quot; 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 &quot;Reset&quot; or &quot;R&quot; 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

\$\endgroup\$
  • \$\begingroup\$ Don't include gui.py (the generated file) as part of the code review request. Include gui.ui (the source file) or perhaps a screen shot if you want someone to comment on the UI layout aspect. \$\endgroup\$ – Brandin Feb 16 '17 at 12:33
3
\$\begingroup\$

List of major edits in PainterField module:

1) Removed useless import from beginning (main module doesn’t directly use custom classes).

2) Iteration over dictionary elements:

Had a lot of (see def draw_field()):

for xy in squares.keys():
    square = dict[xy]
    do_smth

Fixed with:

for square in dict.squares():
    do_smth

3) Used dictionary mapping instead of multiple elif statements in def keyPressEvent():

keymap = {Qt.Key_Enter:  self.start,
          Qt.Key_Return: self.start,
          Qt.Key_Escape: self.close,
          Qt.Key_R:      self.reset
          }

# Player movement
if key in {Qt.Key_Left,  Qt.Key_Right,
           Qt.Key_Up,    Qt.Key_Down}:
    self.key = key

# Starting new game, exiting or resetting current field
elif key in keymap:
    keymap[key]()

4) def results(), which creates text was edited a bit and moved to FieldFunctions.py

5) def turn() and def turn_loop() readability was improved:

  • Impoved iteration over dictionary elements as in p. 2.
  • Instead of

    elif ID == 0: key = self.key ... xy = self.turn(0)

used:

elif player == self.players['You']:
    key = self.key
...
xy = self.turn(self.players['You']) 

self.players now looks like {'You': PlayerQGraphics(), 'Player 1': PlayerQGraphics(), ...}

  • random.choice() instead of random.sample(, 1)

  • "Computer ('ai') turn" section now is:

    if xy is not None: ai_players = sorted(self.players.keys())
    ai_players.remove('You')
    for ai in ai_players:
    self.turn(self.players[ai])

So computer players now make turn in strict order.

List of major edits in FieldClasses module:

1) Bounding rectangles of QGraphicsItem subclasses now fit images and are change where needed by .prepareGeometryChange() method instead of .update()

def boundingRect(self):
    x, y = self.xy
    return QRectF(x*30, y*30, 28, 28)

2) obstacles is now set as it is called multiple times inside nested function.

3) In def findDirections() instead of:

for n in range(1, 5):            
    xy = self.prepareGoto(n, exceptColours, obstacles, squares)

used

for key in {Qt.Key_Left, Qt.Key_Right,
            Qt.Key_Up,   Qt.Key_Down}:
    xy = self.prepareGoto(exceptColours, key, obstacles, squares)

which is self explanatory inside code)

4) Yet again beautiful dictionary mapping in def prepareGoto():

x, y = self.xy
    moves_map = {Qt.Key_Up:    (x,   y-1),
                 Qt.Key_Down:  (x,   y+1),
                 Qt.Key_Left:  (x-1, y),
                 Qt.Key_Right: (x+1, y)
                 }        
    xy = moves_map[key]

5) And the second part of def prepareGoto() also looks more readable since try: except statements handle exactly one operation:

# Prevent movement outside the field bounds
try:
    target_square = squares[xy]
except KeyError:
    return None

# Prevent movement in other players. Can be replaced with
# or statement, but that looks ugly and might be a bit slower that elif.
if xy in obstacles:
    xy = None
elif target_square.colour in exceptColours:
    xy = None        
return xy

Brief edits in FieldFunctions:

1) Yet AGAIN dictionary mapping was used in def create_players() to iterate over:

colours_map = {'You':      ((0,0), 'green'), 'Player 1': ((8,0), 'red'),
               'Player 2': ((0,8), 'blue'),  'Player 3': ((8,8), 'black')}

for name, parameters in colours_map.items():
    xy, colour = parameters        
    players[name] = PlayerQGraphics(xy, colour, icon='graphics/ai.png')
else: # else here looks a bit better that just one line of code after for loop
    players['You'].icon = 'graphics/player.png'    
return players

2) Slightly different version of def print_results() was moved from main module to FieldFunctions.py

Conclusion:

I'm still opened to suggestions though it seems that code is quite optimized now. So probably next post will be about snake game clone, which I wrote 2 times faster because used this game as a template)))

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.