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 "Start" or
"Enter" key. Use arrow keys to move snake.</p>
<p>To pause game press "P" key.</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_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
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"))