2
\$\begingroup\$

1) This a follow-up question to Example of PyQt5 simple turn-based game code

2) After improvement I edited original code to create snake clone game, which is real-time (or pseudo real-time) and it is more simple, than existing turn-based game.

Still my example is a bit over-complicated because I use blocks instead of background (can get rid of custom SquareQGraphics class and just draw background from picture), snake head rotation and simple user interface (buttons and status text).

For those who are curious this is another PyQt example (not mine) which is 1,5-2 times shorter, but do not implement additional features.

3) Any advice, reviews are welcome, since I'm going to use this code as template for other more complicated game.

Snake.py (main module):

#!/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 (QTimer, Qt)
from PyQt5.QtWidgets import (QApplication,  QGraphicsScene,
                             QGraphicsView, QMainWindow, QPushButton)

# Generated gui module
from gui import Ui_Field as Ui_MainWindow

# Custom classes and functions
from SnakeClasses   import (BonusQGraphics, SnakepartQGraphics, SquareQGrapics)
from SnakeFunctions import (add_bonus,    create_bonuses, create_squares,
                            create_snake, print_main,     print_results,
                            print_rules)


'''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.end = False
        self.timer_interval = 350
        self.key = Qt.Key_Up
        self.snake = create_snake()
        self.scene = QGraphicsScene()
        self.squares_xy, squares = create_squares(9, 9)

        self.bonuses = create_bonuses(self.snake, self.squares_xy)

        '''Timer is used to repeat self.turn_loop and detect if key was changed.
        It is stopped by default and starts after self.start() is called.'''
        self.timer = QTimer(self)

        self.draw_field(self.bonuses, squares, self.snake)        
        self.QGraph_field.setScene(self.scene)
        self.print_text(print_main())

        # 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, bonuses, squares, snake):
        for square in squares:
            self.scene.addItem(square)
        for snakepart in snake.values():
            self.scene.addItem(snakepart)
        for bonus in bonuses.values():
            self.scene.addItem(bonus)

    def keyPressEvent(self, event):
        key_map = {Qt.Key_Enter:  self.start,  Qt.Key_Return: self.start,
                   Qt.Key_Escape: self.close,  Qt.Key_P:      self.pause,
                   Qt.Key_R:      self.reset}
        key = event.key()

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

        # Start, pause, reset or exit game        
        elif key in key_map:
            key_map[key]()        

    def pause(self):
        if self.timer.isActive():
            self.timer.stop()       

    def print_text(self, source):        
        self.QText_status.setHtml(source)

    def reset(self):
        self.timer.stop()

        self.end = False        
        self.key = Qt.Key_Up
        self.snake = create_snake()  
        self.squares_xy, squares = create_squares(9, 9)

        self.bonuses = create_bonuses(self.snake, self.squares_xy)

        self.scene.clear()
        self.draw_field(self.bonuses, squares, self.snake)
        self.print_text(print_main())

    def start(self):
        if not self.end and not self.timer.isActive():            
            self.timer.start(self.timer_interval)    

    def turn_loop(self):
        isCollided, self.key = self.snake.collides(self.key)        
        head = self.snake[0]
        if isCollided:
            self.timer.stop()            
            self.end = True            
            self.print_text(print_results(self.snake))
        else:
            empty_xy = self.snake.move(self.key)
            eaten_bonus = self.snake.eats_bonus(self.bonuses)
            if eaten_bonus is not None:
                self.scene.removeItem(eaten_bonus)
                new_tail = self.snake.grow(empty_xy)
                new_bonus = add_bonus(self.bonuses, self.snake, self.squares_xy)                
                self.scene.addItem(new_tail)
                self.scene.addItem(new_bonus)

if __name__ == "__main__":    
    app = QApplication(sys.argv)
    window = MyApp()    
    window.show()    
    sys.exit(app.exec_())

SnakeFunctions.py:

import itertools
import random

#Custom classes
from SnakeClasses import (BonusQGraphics, SnakeDict,
                          SnakepartQGraphics, SquareQGrapics)

def add_bonus(bonuses, snake, squares):
    # Create set of empty squares coordinates
    free_squares = squares.copy()    
    obstacles = itertools.chain(snake.values(), bonuses.values())
    for obstacle in obstacles:
        if obstacle.xy in free_squares:
            free_squares.remove(obstacle.xy)

    # After set is comleted randomly place bonus
    free_squares = tuple(free_squares)      
    new_bonus = BonusQGraphics(xy=random.choice(free_squares))
    bonuses[new_bonus.xy] = new_bonus
    return new_bonus

def create_bonuses(snake, squares):
    bonuses = {}
    for call in range(2):
        add_bonus(bonuses, snake, squares)
    return bonuses


def create_squares(cols, rows):
    squares = {}
    for x in range(cols):
        for y in range(rows):
            squares[(x,y)] = SquareQGrapics((x,y))
    squares_xy, squares = set(squares.keys()), set(squares.values())
    return squares_xy, squares

def create_snake():
    snake = SnakeDict()
    x, y = (0, 3)
    snake[0] = SnakepartQGraphics ((x, y), icon='graphics/head.png') # head
    for n in range(1, 3):
        y += 1
        snake[n] = SnakepartQGraphics ((x, y))    
    return snake

def print_main():
    text = ('''<p>Welcome to Snake game!</p>

            <p>To start or unpause game press &quot;Start&quot; or
            &quot;Enter&quot; key. Use arrow keys to move snake.</p>

            <p>To pause game press &quot;P&quot; key.</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_results(snake):
    score = len(snake) - 3
    text = ('''<p>You end up with score:</p>
            <p>{}</p>'''.format(score))
    return text

def print_rules():
    text = ('''<p>Player gains score when snake eats apple.</p>

            <p>When this happens snake aslo grows by one tile.</p>

            <p>Game ends when snake runs in the border or itself.</p>''')
    return text

SnakeClasses.py:

from PyQt5.QtCore    import (QRectF, Qt)
from PyQt5.QtGui     import (QBrush, QColor, QImage)
from PyQt5.QtWidgets import QGraphicsItem

class BonusQGraphics(QGraphicsItem):
    def __init__(self, xy, icon='graphics/bonus.png'):
        QGraphicsItem.__init__(self)
        self.icon = icon
        self.xy = xy

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

    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)    

class SnakeDict(dict):
    def __init__(self):
        dict.__init__(self)

    def collides(self, key):
        '''Prevents snake head from moving backwards (collision of head
        with previous body block) and detects its collision with bounds or
        snake body. Returns True and key=None if collision. Otherwise
        returns False and key.'''

        head, tail = self[0], self[len(self)-1]
        isCollided = False

        opposite_keys = {Qt.Key_Up: Qt.Key_Down,
                         Qt.Key_Down: Qt.Key_Up,
                         Qt.Key_Left: Qt.Key_Right,
                         Qt.Key_Right:Qt.Key_Left
                         }        
        # Preventing moving backwards (inside itself)
        x, y, angle = head.prepareGoto(key)
        if (x, y) == self[1].xy:
            key = opposite_keys[key]
            x, y, angle = head.prepareGoto(key)

        # Collision with bounds
        bounds_set = {(x<0), (x>8), (y<0), (y>8)}
        if True in bounds_set:
            isCollided, key = True, None
            return isCollided, key

        # Collision with body, tail will move next turn so it is not counted
        for snakepart in self.values():
            if snakepart not in (head, tail):                
                if (x, y) == snakepart.xy:
                    isCollided, key = True, None
                    break 

        # No collision
        return isCollided, key

    def eats_bonus(self, bonuses):
        head = self[0]        
        if head.xy in bonuses.keys(): # Head eats bonus
            eaten_bonus = bonuses.pop(head.xy)          
        else:
            eaten_bonus = None
        return eaten_bonus

    def grow(self, empty_xy):
        new_tail = SnakepartQGraphics(empty_xy)        
        self[len(self)] = new_tail
        return new_tail        

    def move(self, key):
        '''Returns last coordinate at which new snakepart can be added.'''

        head = self[0]
        new_xy = head.goto(key)        
        body = sorted(self.items())[1:]

        for ID, snakepart in body:
            old_xy = snakepart.xy
            new_xy = snakepart.gotoxy(new_xy)
        return old_xy

class SnakepartQGraphics(QGraphicsItem):
    def __init__(self, xy, icon=None):        
        QGraphicsItem.__init__(self)       
        self.icon = icon        
        self.xy = xy        

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

    def goto(self, key):
        '''Based on prepareGoto() moves snakepart to calculated x, y and
        rotates it. Returns initial x, y.'''

        prev_xy = self.xy[:]
        x, y, angle = self.prepareGoto(key)   
        self.prepareGeometryChange()
        self.xy = x, y
        self.setTransformOriginPoint(self.boundingRect().center())
        self.setRotation(angle)
        return prev_xy

    def gotoxy(self, xy, angle=0):
        '''Moves to designated x, y. Returns initial x, y.'''

        prev_xy = self.xy[:]
        self.prepareGeometryChange()
        self.xy = xy
        return prev_xy

    def paint(self, painter, option, widget):
        x, y = self.xy
        if self.icon is None:
            colour = QBrush(QColor('green'))
            painter.setBrush(colour)
            painter.drawRect(x*30, y*30, 28, 28)
        target = QRectF(x*30, y*30, 28, 28)
        source = QRectF(0, 0, 28, 28)
        painter.drawImage(target, QImage(self.icon), source)

    def prepareGoto(self, key):
        '''Calculates x, y and rotation (for head)'''

        x, y = self.xy
        moves = {Qt.Key_Up: (x, y-1, 0), Qt.Key_Down: (x, y+1, 180),
                 Qt.Key_Left: (x-1, y, -90), Qt.Key_Right:(x+1, y, 90)}

        x, y, angle = moves[key]
        return x, y, angle

# Arbitrary class. Is used just to quickly draw rectangles.
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 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)

Rename those to bonus.png and head.png and place in \graphics\ folder bonus head

Also gui.py generated from file created in Qt Designer:

# -*- coding: utf-8 -*-

# Form implementation generated from reading ui file 'gui.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))
        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.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
        self.QGraph_field.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
        self.QGraph_field.setSceneRect(QtCore.QRectF(0.0, 0.0, 270.0, 270.0))
        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.vertical_layout = QtWidgets.QVBoxLayout(self.verticalLayoutWidget)
        self.vertical_layout.setContentsMargins(0, 0, 0, 0)
        self.vertical_layout.setObjectName("vertical_layout")
        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.setTabStopWidth(79)
        self.QText_status.setObjectName("QText_status")
        self.vertical_layout.addWidget(self.QText_status)
        self.horizontal_layout1 = QtWidgets.QHBoxLayout()
        self.horizontal_layout1.setObjectName("horizontal_layout1")
        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.horizontal_layout1.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.horizontal_layout1.addWidget(self.QBut_rules)
        self.vertical_layout.addLayout(self.horizontal_layout1)
        self.horizontal_layout2 = QtWidgets.QHBoxLayout()
        self.horizontal_layout2.setObjectName("horizontal_layout2")
        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.horizontal_layout2.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.horizontal_layout2.addWidget(self.QBut_main)
        self.vertical_layout.addLayout(self.horizontal_layout2)

        self.retranslateUi(Field)
        QtCore.QMetaObject.connectSlotsByName(Field)

    def retranslateUi(self, Field):
        _translate = QtCore.QCoreApplication.translate
        Field.setWindowTitle(_translate("Field", "PainterField"))
        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"))
\$\endgroup\$

Your Answer

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

Browse other questions tagged or ask your own question.