Breakout With Tkinter
Table of Contents
A tkinter breakout implementation from the book Python Game Programming by Example
. Although Tkinter is referred to primarily as a GUI builder, this example shows you how to use it to re-create the arcade game Breakout using only the python standard library.
Pre-Installation
Although the tkinter
python library is part of the standard python installation, it relies on Tcl/Tk and a c-python binary that you have to build or install. To get it in ubuntu you can use apt-get
.
sudo apt-get python3-tk
Imports
# python standard library
from abc import ABCMeta
from abc import abstractproperty
from collections import namedtuple
from copy import copy
import re
import string
import tkinter
Constants
BLUE = "#0000ff"
LAVENDER = "#aaaaff"
WHITE = "#ffffff"
NUMERIC = (int, float)
REVERSE_DIRECTION = -1
LEFT = -1
RIGHT = LEFT * REVERSE_DIRECTION
UP = -1
EDGE_OF_SCREEN = 0
NO_MOVEMENT = 0
Base Classes
Coordinates
This is a named tuple to see if I can keep the coordinates returned by tkinter straight.
Coordinates = namedtuple("Coordinates", ["top_left_x",
"top_left_y",
"bottom_right_x",
"bottom_right_y"])
Base Widget
A base-class to implement the methods common to all the game objects. The python 3 syntax seems to have changed slightly so you have to use metaclass=ABCMeta
in place of object
instead of putting __metaclass__==ABCMeta
the way I used to in python 2.7.
The item
property
class BaseWidget(metaclass=ABCMeta):
"""base class for game object's
Parameters
----------
canvas: tkinter.Canvas
canvas to draw on
"""
@abstractproperty
def item(self):
"""the canvas item"""
return
The item
will be an identifier pointing to a tkinter widget. It gets returned by the function call to create the widget (e.g. canvas.create_oval
).
Position
@property
def position(self):
"""the coordinates of the object on the canvas
Returns
-------
tuple: x0, y0, x1, y1
"""
x0, y0, x1, y1 = self.canvas.coords(self.item)
return Coordinates(top_left_x=x0,
top_left_y=y0,
bottom_right_x=x1,
bottom_right_y=y1)
The call to canvas.coords
returns the coordinates of the bounding-box for the widget. The first two values are the x and y coordinates for the upper-left corner of the box and the last two values are the x and y coordinates of the bottom-right corner.
Move
def move(self, horizontal_offset, vertical_offset):
"""move this object to the coordinates
Parameters
----------
horizontal_offset: int
x-axis pixels to move
vertical_offset: int
y-axis pixels to move
"""
self.canvas.move(self.item, horizontal_offset, vertical_offset)
return
This moves the object. Rather than giving it coordinates, the move
method moves the object vertically and horizontally the number of pixels you pass in for each axis. You could think of the values as being added to each of the coordinates (so negative numbers will move the object in the opposite direction from positive numbers).
Delete
def delete(self):
"""destroy this canvas item"""
self.canvas.delete(self.item)
return
This deletes the object from the canvas. If you attempt to use it after this tkinter will raise an exception.
Base Settings
A base class to hold settings. It (partly) implements the fluent interface design pattern. I'm not one-hundred percent convinced that it's a good idea in python, but I wanted to add value checking and document the values I expect a little better.
class BaseSettings(metaclass=ABCMeta):
"""fluent interface"""
_hex_pattern = None
@abstractproperty
def attributes(self):
"""list of attribute names
This is used by the call to check the attributes
"""
return
The attributes
will be a list (or tuple) of strings that match the variable-names in the settings objects that are required (e.g. to require that self.table
has a value other than None, put "table" in the list). This gets used by the __call__
method (defined below).
@property
def hex_pattern(self):
"""compiled regex to match hex-colors"""
if BaseSettings._hex_pattern is None:
hex_character = "\da-fA-f"
base = "(?P<{{0}}[{0}])(?P={{0}})".format(hex_character)
BaseSettings._hex_pattern = re.compile("#" +
base.format("r") +
base.format("g") +
base.format("b"))
return BaseSettings._hex_pattern
The hex_pattern
will match any string that starts with a pound sign (#) followed by three sets of hex-digit pairs. A hex-digit is an integer from 0 to 9 or one of the letters from a to f (case insensitive). Since it requires pairs, each hex-digit has to be repeated twice. So it will match #aabbff but not #abf or #0123ab. It's used by the next method.
def check_hex_color(self, value, identifier):
"""checks the color is a valid hex-code
Parameters
----------
value: string
color-code to check
identifier: string
error-message identifier
Raises
------
TypeError: if string is malformed
"""
if not re.match("#" + "[{0}]".format(string.hexdigits) * 6, value):
raise TypeError("{0} must be a 6-digit hex string, not {1}".format(
identifier, value))
return
The colors set in this code are based on RGB hex-strings. The check_hex_color
method validates that they are look something like "#aabbcc".
def assert_positive_number(self, value, identifier):
"""checks the value
Parameters
----------
value: int or float
value to check
identifier: string
something for the error message
Raises
------
TypeError if value is not a positive number
"""
self.check_numeric(value, identifier)
self.check_positive(value, identifier)
return
assert_positive_number
checks that the value
passed in is a number greater than 0.
def check_positive(self, value, identifier):
"""check that value is greater than zero
Parameters
----------
value: numeric
value to check
identifier: str
description for error messages
Raises
------
TypeError: if value is <= 0
"""
if not value > 0:
raise TypeError("{0} must be greater than 0 not {1}".format(
identifier,
value))
return
The check_positive
method raises a TypeError
if the value
passed in isn't greater than zero. It doesn't check that the value
is numeric so if it isn't it will still raise a TypeError
but the error message won't be as helpful. I did it this way because I (at least originally) assumed some values had to be integers so I wanted to leave the check for type as a separate operation.
def check_type(self, thing, identifier, expected):
"""checks that the value is the correct type
Parameters
----------
thing:
object to check
identifier: string
message to identify the thing
expected: object
what the thing is expected to be
Raises
------
TypeError: if thing isn't as expected
"""
if not isinstance(thing, expected):
raise TypeError("Expected {0} to be {1} not {2}".format(identifier,
expected,
thing))
return
The check_type
was the original method I created. I'm not sure it's as useful as checking ranges of values (which I'm not doing enough of yet), but it's at least useful to check if my expectations of what's being passed in to the methods is correct.
def check_types(self, thing, identifier, expected):
"""check that thing is one of multiple types
Parameters
----------
thing: object
thing to check
identifier: string
identifier for error message
expected: collection
types that thing might be
Raises
------
TypeError if type of thing not in expected
"""
if not type(thing) in expected:
raise TypeError("{0} should be one of {1}, not {2}".format(
identifier,
expected,
thing))
return
check_types
allows you you specify a collection of possible types for the value.
def check_numeric(self, thing, identifier):
"""check if thing is int or float
Parameters
----------
thing: object
thing to check if is numeric
identifier: string
identifier for error message
Raises
------
TypeError if thing is not numeric
"""
self.check_types(thing, identifier, NUMERIC)
return
check_numeric
will raise a TypeError
if the value isn't an integer or float.
def __call__(self):
"""checks that everything was set
Raises
------
TypeError:
if any attributes weren't set
Returns
-------
GameSettings: this object
"""
for attribute in self.attributes:
if getattr(self, attribute) is None:
raise TypeError("{0} attribute not set".format(attribute))
return self
The __call__
is meant to be the final method called when the parameters are set. It checks that all the properties in the attributes
list have been set to something other than None
and raises a TypeError
if any of them hasn't been set.
The Ball Class
Ball Directions
This is an object to use instead of the list like they use in the book. The first value (x-direction) is set positive to make it move from left to right, and negative to move right to left. The second value is set positive to move the ball downwards and negative to move it upwards.
class BallDirections(object):
"""holds the current direction of the ball
Parameters
----------
horizontal: int
positive to move left to right, negative otherwise
vertical: int
positive to move down, negative to move up
"""
def __init__(self, horizontal, vertical):
self.horizontal = horizontal
self.vertical = vertical
return
Ball Settings
class BallSettings(BaseSettings):
"""settings for the ball"""
def __init__(self):
self.x = None
self.y = None
self.radius = None
self.direction = None
self.speed = None
self.fill = None
self._attributes = None
return
@property
def attributes(self):
"""required attributes"""
if self._attributes is None:
self._attributes = ("x",
"y",
"radius",
"direction",
"speed",
"fill")
return self._attributes
def x_position(self, x):
"""initial horizontal position
Parameters
----------
x: int or float
pixels from the left of the canvas to start the ball
"""
self.x = x
self.assert_positive_number(x, "x")
return self
def y_position(self, y):
"""initial vertical position
Parameters
----------
y: int
pixels from the top of the canvas
"""
self.y = y
self.assert_positive_number(y, "y")
return self
The x
and y
values for the ball are actually set in the code based on the initial location of the paddle, so requiring them here is a bad idea. Oh, well.
def circle_radius(self, radius):
"""radius of the ball
Parameters
----------
radius: int
pixel width and height for the circle
"""
self.radius = radius
self.assert_positive_number(radius, "radius")
return self
Ovals in tkinter are set by specifying the corners of their bounding boxes, the same as with creating a rectangle. So the radius
is used as an offset to calculate where the corners should be. For example, if you have the center x-value for the oval, subtracting the radius gives you the top-left x-value and adding the radius gives you the bottom-right x-value. See the Game.ball
property to get an idea of how it's used.
def direction_vector(self, direction):
"""2-d vector for direction"""
self.direction = direction
self.check_type(direction, "ball direction", BallDirections)
return self
The direction
values determine what direction the object is moving on the vertical and horizontal axes. Positive values move to the right and down, while negative values move to the left and up.
Horizontal | Vertical | Direction |
---|---|---|
Positive | Positive | Down-Right |
Positive | Negative | Up-Right |
Negative | Positive | Down-Left |
Negative | Negative | Up-Left |
def velocity(self, speed):
"""speed of the ball
Parameters
----------
speed: number
pixels per move
"""
self.speed = speed
self.assert_positive_number(speed, "speed")
return self
def color(self, fill):
"""fill color
Parameters
----------
fill: str
hex-color to fill in the ball
"""
self.fill = fill
self.check_hex_color(fill, "fill")
return self
The speed
and fill
are the number of pixels to move the ball each time and the fill
is the color to put inside it.
The Ball
The ball-widget holds the reference to the ball that the player uses to smash bricks to try and break-out.
class BallWidget(BaseWidget):
"""representation of the ball
Parameters
----------
canvas: tkinter.Canvas
what to create the ball from
settings: BallSettings
initial ball settings
"""
def __init__(self, canvas, settings):
self.canvas = canvas
self.settings = settings
self._item = None
self.direction = self.settings.direction
self.speed = self.settings.speed
return
@property
def item(self):
"""canvas item representing the ball"""
if self._item is None:
x, y = self.settings.x, self.settings.y
radius = self.settings.radius
self._item = self.canvas.create_oval(
x-radius, y-radius,
x+radius, y+radius,
fill=self.settings.fill,
)
return self._item
def update(self):
"""moves the ball
if the ball hits something, reverses direction
"""
ball = self.position
width = self.canvas.winfo_width()
if ball.top_left_x <= EDGE_OF_SCREEN or ball.bottom_right_x >= width:
self.direction.horizontal *= REVERSE_DIRECTION
if ball.top_left_y <= EDGE_OF_SCREEN:
self.direction.vertical *= REVERSE_DIRECTION
self.move(self.direction.horizontal * self.speed,
self.direction.vertical * self.speed)
The update
method gets its current position and if it is off-screen on either side it inverts the horizontal direction. If the ball is above the top of the screen it reverses its vertical direction. It doesn't check the bottom of the screen because going off the bottom is how the player loses so it's an expected behavior. Once it has the directions set it moves the ball by the amount defined by the speed
variable.
def collide(self, others):
"""handles collisions
Parameters
----------
others: list
collection of ther objects that the ball collided with
"""
if len(others) > 1:
self.direction.vertical *= REVERSE_DIRECTION
elif len(others) == 1:
ball = self.position
x = (ball.top_left_x + ball.bottom_right_x)/2
other = others[0].position
if x > other.bottom_right_x:
self.direction.horizontal = RIGHT
elif x < other.top_left_x:
self.direction.horizontal = LEFT
else:
self.direction.vertical *= REVERSE_DIRECTION
for other in others:
if isinstance(other, BrickWidget):
other.hit()
return
The collide
method handles when a ball collides with another object. If it collided with more than one object it always reverses directions (this would only happen with bricks, not the paddle). If it collided with a single object then if the object is to the left of it (the ball's mean x-value is greater than the rightmost x-value for the object) then it sets its horizontal direction to move to the right (it bounces off it to the right). If the other object is to the right of the ball then the ball moves to the left. Otherwise the ball hit the object on top or below it so it changes vertical direction. If any of the objects are bricks then their hit
methods are called.
The Paddle
A representation of the player's paddle.
The Paddle Settings
class PaddleSettings(BaseSettings):
"""settings for the player's paddle"""
def __init__(self):
self._attributes = None
self.width = None
self.height = None
self.speed = None
self.x = None
self.y = None
self.fill = None
return
@property
def attributes(self):
"""list of required settings"""
if self._attributes is None:
self._attributes = ("width",
"height",
"speed",
"x",
"y",
"fill")
return self._attributes
def pixel_width(self, width):
"""width of the paddle
Parameters
----------
width: int
pixel-width for the paddle
"""
self.width = width
self.check_type(width, "width", int)
self.check_positive(width, "width")
return self
def pixel_height(self, height):
"""height of the paddle
Parameters
----------
height: int
pixel-height of the paddle
"""
self.height = height
self.check_type(height, "height", int)
self.check_positive(height, "height")
return self
The height and width are offsets to add to the upper-left corner coordinates of the bounding box to locate the lower-right corner of the bounding box, thus defining the size of the paddle.
def velocity(self, speed):
"""rate at which to move the paddle
Parameters
----------
speed: number
amount to move paddle with each key stroke
"""
self.speed = speed
self.assert_positive_number(speed, "paddle speed")
return self
The speed
of the paddle is the amount it will move every-time an arrow key is hit. I think it's in pixels, but the units aren't clear.
def x_position(self, x):
"""initial x-position
Parameters
----------
x: int
pixels from the left of the canvas
"""
self.x = x
self.check_numeric(x, "x")
self.check_positive(x, "x")
return self
def y_position(self, y):
"""initial y-position
Parameters
----------
y: int
pixels from the top of the canvas
"""
self.y = y
self.check_numeric(y, 'y')
self.check_positive(y, "y")
return self
The x
and y
settings determine where the paddle will be at the start (and since it only moves horizontally the y
value is where it will be vertically throughout the game).
def color(self, fill):
"""fill color for the rectangle
Parameters
----------
fill: str
hex-code for the fill color
"""
self.fill = fill
self.check_hex_color(fill, "fill")
return self
Like with the Ball the fill
value for the Paddle decides what color to fill it with.
The Paddle Class
class PaddleWidget(BaseWidget):
"""the player's paddle
Parameters
----------
canvas: tkinter.Canvas
the canvas to draw on
settings: PaddleSettings
initial settings for the paddle
"""
def __init__(self, canvas, settings):
self.canvas = canvas
self.settings = settings
self._item = None
self.ball = None
return
@property
def item(self):
"""the canvas item for the paddle"""
if self._item is None:
half_width = self.settings.width/2
half_height = self.settings.height/2
x, y = self.settings.x, self.settings.y
self._item = self.canvas.create_rectangle(
x - half_width, y - half_height,
x + half_width, y + half_height,
fill=self.settings.fill
)
return self._item
The item
property creates a rectangle of width x height
dimensions centered around (x, y)
.
def move(self, offset):
"""moves the paddle
if has a ball, also moves the ball
if already flush left or flush right, does nothing
Parameters
----------
offset: int
amount to move the paddle and ball horizontally
"""
coordinates = self.position
width = self.canvas.winfo_width()
if (coordinates.top_left_x + offset >= 0 and
coordinates.bottom_right_x + offset <= width): # noqa: E129
super(PaddleWidget, self).move(offset, 0)
if self.ball is not None:
self.ball.move(offset, 0)
return
The move
method moves the paddle horizontally by some offset. If moving it would place it offscreen to the left or right then it doesn't do anything. The paddle should only have the ball before the game starts (so that if the player moves the paddle the ball will stay with it until the game starts).
The Brick
Brick Settings
class BrickSettings(BaseSettings):
"""settings for the brick widget"""
def __init__(self):
self.x = None
self.y = None
self.width = None
self.height = None
self.colors = None
self.tags = None
self._attributes = None
self._hits = None
return
@property
def attributes(self):
"""list of required values"""
if self._attributes is None:
self._attributes = ("width",
"height",
"colors",
"tags",
"x",
"y",
"hits")
return self._attributes
@property
def hits(self):
"""the number of hits each brick will take
"""
if self._hits is None:
self._hits = max(self.colors)
return self._hits
def maximum_hits(self, hits):
"""number of hits brick will take
Parameters
----------
hits: int
number of hits before deleting bricks
"""
self._hits = hits
self.check_type(hits, "hits", int)
self.check_positive(hits, "hits")
return self
The hits
value is the number of times a brick gets hit by a ball before it deletes itself. I originally make it always use the largest value but then found out different rows use different values so I added a setter method.
def x_position(self, x):
"""horizontal position
Parameters
----------
x: int or float
pixels from the left
"""
self.x = x
self.check_numeric(x, "x")
self.check_positive(x, "x")
return self
def y_position(self, y):
"""vertical position
Parameters
----------
y: int or float
pixels from the top
"""
self.y = y
self.check_numeric(y, "y")
self.check_positive(y, "y")
return self
The x
and y
are the center-positions for a brick. Since they don't move this is their permanent position. Like the ball, this actually gets calculated when the game is set up so making this required was probably a bad ide.
def pixel_width(self, width):
"""width of the brick
Parameters
----------
width: int
pixel-width of the brick
"""
self.width = width
self.check_type(width, "width", int)
self.check_positive(width, "width")
return self
def pixel_height(self, height):
"""height of the brick
Parameters
----------
height: int
pixel-height of the brick
"""
self.height = height
self.check_type(height, "height", int)
self.check_positive(height, "height")
return self
The height
and width
give the dimensions of the brick.
def level_colors(self, colors):
"""map of level to colors
Parameters
----------
colors: dict
map of integers to colors
"""
self.colors = colors
for level in range(1, len(colors) + 1):
if level not in colors:
raise TypeError("colors keys must be range starting at 1")
for level, color in colors.items():
self.check_hex_color(color, "level {0} color".format(level))
return self
As a brick gets hit it changes colors. The colors
dictionary is a mapping between the number of remaining times the brick can be hit before being deleted (the level of the brick) and the color for that level.
def label(self, tags):
"""string to tag bricks
Parameters
----------
tags: str
identifier for bricks
"""
self.tags = tags
self.check_type(tags, "tags", str)
return self
The tags
attribute is a string given to tkinter to identify a class of related widgets.
Brick Widget
class BrickWidget(BaseWidget):
"""represents a single brick
Parameters
----------
canvas: tkinter.Canvas
what to draw the brick on
settings: BrickSettings
initial settings for the brick
"""
def __init__(self, canvas, settings):
self.canvas = canvas
self.settings = settings
self.hits = self.settings.hits
self._item = None
return
@property
def item(self):
"""canvas rectangle"""
if self._item is None:
half_height = self.settings.height/2
half_width = self.settings.width/2
x, y = self.settings.x, self.settings.y
self._item = self.canvas.create_rectangle(
x - half_width, y - half_height,
x + half_width, y + half_height,
fill=self.settings.colors[self.hits],
tags=self.settings.tags
)
return self._item
The item
creation is almost the same as the one for the Paddle except that the color is based on the number of remaining hits it starts with and it gets a tag
def hit(self):
"""the brick has been hit event
Decrements the counter and changes the color or deletes the brick
"""
self.hits -= 1
if self.hits == 0:
self.delete()
else:
self.canvas.itemconfig(self.item,
fill=self.settings.colors[self.hits])
return
The hit
method decrements the number of hits the brick has remaining and deletes it if it doesn't have any left. If it does have hits left it re-colors the brick to match the number of hits remaining.
The Frame
The Frame Settings
These are settings for the Tkinter Frame.
class FrameSettings(BaseSettings):
"""holds the settings for the game"""
def __init__(self):
self.width = None
self.height = None
self.color = None
self.title = None
self._attributes = None
return
@property
def attributes(self):
"""list of required attributes"""
if self._attributes is None:
self._attributes = ("width",
"height",
"color",
"title")
return self._attributes
def window_width(self, width):
"""width of window
Parameters
----------
width: int
pixel-width for the tkinter window
Returns
-------
GameSettings: this object
"""
self.width = width
self.check_type(width, "width", int)
self.check_positive(width, "width")
return self
def window_height(self, height):
"""height of window
Parameters
----------
height: int
pixel height of the window
Returns
-------
GameSettings: this object
"""
self.height = height
self.check_type(height, "height", int)
self.check_positive(height, "height")
return self
def canvas_color(self, color):
"""background color
Parameters
----------
color: string
hex-color for canvas background
Returns
-------
GameSettings: this object
"""
self.color = color
self.check_hex_color(color, 'background color')
return self
def window_title(self, title):
"""title of the window
Parameters
----------
title: str
name to give the title
Returns
-------
GameSettings: this object
"""
self.title = title
self.check_type(title, "window title", str)
return self
The Frame Class
The Tk
class creates the main window. Within it the Frame
class creates a container which you pass the main window on instantiation. Within the frame a Canvas
is placed to actually draw things. The pack
method tells the children to display their widgets on their parents.
class BreakoutFrame(tkinter.Frame):
"""creates the breakout game
Parameters
----------
settings: GameSettings
object with the settings
parent: Tk
parent window for this frame
"""
def __init__(self, settings, parent):
super(BreakoutFrame, self).__init__(parent)
self.parent = parent
self.parent.title(settings.title)
self.settings = settings
self._canvas = None
self.height = settings.height
return
@property
def canvas(self):
"""canvas to render images"""
if self._canvas is None:
self._canvas = tkinter.Canvas(self,
width=self.settings.width,
height=self.settings.height,
bg=self.settings.color)
return self._canvas
def __call__(self):
"""runs the main-loop"""
self.canvas.pack()
self.pack()
self.parent.mainloop()
return
The Game
Game Settings
class GameSettings(BaseSettings):
"""settings for the game"""
def __init__(self):
self.lives = None
self.text_x = None
self.text_y = None
self.text_size = None
self.padding = None
self._attributes = None
return
@property
def attributes(self):
"""required attributes"""
if self._attributes is None:
self._attributes = ("lives",
"text_x",
"text_y",
"padding")
return self._attributes
def font_size(self, size):
"""text size in pixels
Parameters
----------
size: int
size for fonts
"""
self.text_size = size
self.check_type(size, "text size", int)
self.check_positive(size, "text size")
return self
def allowed_failures(self, lives):
"""number of times player can fail
Parameters
----------
lives: int
number of failures per game
"""
self.lives = lives
self.check_type(lives, "lives", int)
self.check_positive(lives, "lives")
return self
def text_horizontal_position(self, text_x):
"""pixel indent for text
Parameters
----------
text_x: int
number of pixels from the left
"""
self.text_x = text_x
self.check_type(text_x, "text indent", int)
self.check_positive(text_x, "text indent")
return self
def text_vertical_position(self, text_y):
"""pixel vertical position for text
Parameters
----------
text_y: int
pixels from the top
"""
self.text_y = text_y
self.check_type(text_y, "text y", int)
self.check_positive(text_y, "text y")
return self
def outer_padding(self, padding):
"""outer margins
Parameters
----------
padding: int
pixels to put around the edge of the canvas
"""
self.padding = padding
self.check_type(padding, "padding", int)
self.check_positive(padding, "padding")
return self
Game Class
class Game(object):
"""builds and holds the game
Parameters
----------
game_settings: GameSettings
settings for the game overall
frame_settings: FrameSettings
settings to set-up the tkinter window
paddle_settings: PaddleSettings
settings to set-up the paddle_settings
brick_settings: BrickSettings
settings to set-up the bricks
ball_settings: BallSettings
settings to set-up the ball
"""
def __init__(self, game_settings, frame_settings, paddle_settings,
brick_settings, ball_settings):
self.game_settings = game_settings
self.frame_settings = frame_settings
self.paddle_settings = paddle_settings
self.brick_settings = brick_settings
self.ball_settings = ball_settings
self.collidable = {}
self.hud = None
self._frame = None
self._canvas = None
self._paddle = None
self._bricks = None
self._ball = None
return
@property
def frame(self):
"""the tkinter frame"""
if self._frame is None:
self._frame = BreakoutFrame(self.frame_settings, tkinter.Tk())
return self._frame
@property
def canvas(self):
"""tkinter canvas to draw on"""
if self._canvas is None:
self._canvas = self.frame.canvas
return self._canvas
@property
def paddle(self):
"""the paddle widget"""
if self._paddle is None:
(self.paddle_settings.x_position(self.frame_settings.width/2)
.y_position(self.frame_settings.height -
self.game_settings.padding -
self.paddle_settings.height))
self._paddle = PaddleWidget(self.canvas, self.paddle_settings)
return self._paddle
The Paddle is created centered horizontally and at the height specified in the settings.
@property
def ball(self):
"""the ball widget"""
if self._ball is None:
paddle = self.paddle.position
(self.ball_settings
.x_position((paddle.top_left_x + paddle.bottom_right_x)/2)
.y_position(paddle.top_left_y - 2 * self.ball_settings.radius)
.direction_vector(BallDirections(RIGHT, UP)))
self._ball = BallWidget(self.canvas, self.ball_settings)
return self._ball
The ball is created sitting on top of the paddle in its horizontal center (the mean of its x-coordinates). Even though I'm forcing the user to set the x and y values they actually get overwritten here. I also had to make sure that the ball is created above the paddle, which is why I'm adding twice the radius to the y-position, otherwise it would register as a collision and end up going down instead of up.
def add_brick(self, x, y, settings):
"""add a brick to items
Parameters
----------
x: int
pixels from the left
y: int
pixels from the top
"""
settings = (copy(settings)
.x_position(x)
.y_position(y))
brick = BrickWidget(self.canvas, settings)
self.collidable[brick.item] = brick
return
def add_bricks(self):
"""adds the bricks"""
half_width = self.brick_settings.width/2
first_row = 50
second_row = first_row + self.brick_settings.height
third_row = second_row + self.brick_settings.height
first_settings = copy(self.brick_settings).maximum_hits(3)
second_settings = copy(self.brick_settings).maximum_hits(2)
third_settings = copy(self.brick_settings).maximum_hits(1)
for x in range(5, self.frame_settings.width - 5, 75):
this_x = x + half_width
self.add_brick(this_x, first_row, first_settings)
self.add_brick(this_x, second_row, second_settings)
self.add_brick(this_x, third_row, third_settings)
return
The add_bricks
method creates three rows of bricks with a 5-pixel left margin and a 50 pixel top margin. The top-row of bricks takes three hits each, the second two hits each and the bricks in the bottom row will be deleted after one hit.
def setup_canvas(self):
"""sets up some canvas settings"""
self.canvas.focus_set()
self.canvas.bind(
"<Left>",
lambda _: self.paddle.move(-self.paddle_settings.speed)
)
self.canvas.bind(
"<Right>",
lambda _: self.paddle.move(self.paddle_settings.speed)
)
self.canvas.bind("<space>", lambda _: self.start())
return
The setup_canvas
causes the canvas to steal focus and then sets up the keys the user uses to control the paddle and start the game.
def draw_text(self, x, y, text):
"""draws the text
Parameters
----------
x: int
left indent
y: int
right indent
text: string
what to output
Returns
-------
text-object
"""
font = ("Helvetica", self.game_settings.text_size)
return self.canvas.create_text(x,
y,
text=text, font=font)
def update_lives_text(self):
"""updates the text when a player fails"""
text = "Lives: {0}".format(self.lives)
if self.hud is None:
self.hud = self.draw_text(self.game_settings.text_x,
self.game_settings.text_y,
text)
else:
self.canvas.itemconfig(self.hud, text=text)
return
def reset(self):
"""sets up the game after it's ended"""
self.lives = self.game_settings.lives
self.add_bricks()
self.setup_canvas()
self.ball.delete()
self._ball = None
self.paddle.ball = self.ball
self.update_lives_text()
return
def set_up(self):
"""populates the collidable items dict"""
self.lives = self.game_settings.lives
self.collidable[self.paddle.item] = self.paddle
self.add_bricks()
self.setup_canvas()
self.ball.delete()
self._ball = None
self.paddle.ball = self.ball
self.update_lives_text()
self.text = self.draw_text(300, 200, "Press Space to Start")
return
def set_up_in_between(self):
"""sets things up when the player still has lives"""
self.ball.delete()
self._ball = None
self.paddle.ball = self.ball
self.update_lives_text()
self.setup_canvas()
self.text = self.draw_text(300, 200, "Press Space to Start")
return
def start(self):
"""starts the game"""
self.canvas.unbind("<space>")
self.canvas.delete(self.text)
self.paddle.ball = None
self.game_loop()
return
The start
method un-binds the spacebar from the start
method so the game won't restart if the player accidentally hits the spacebar. It also deletes the message to hit the spacebar to start the game, removes the ball from the paddle and starts the game-loop
def game_loop(self):
"""runs the game"""
self.check_collisions()
num_bricks = len(self.canvas.find_withtag("brick"))
if num_bricks == 0:
self.ball.speed = None
self.text = self.draw_text(300, 200, "You Win. Whatever. (Hit the spacebar to restart)")
self.reset()
elif self.ball.position.bottom_right_y >= self.frame.height:
self.ball.speed = None
self.lives -= 1
if self.lives < 0:
self.text = self.draw_text(300, 200, "Loser (Hit the spacebar to restart)")
self.reset()
else:
self.frame.after(1000, self.set_up_in_between)
else:
self.ball.update()
self.frame.after(50, self.game_loop)
return
Besides checking for collisions, the game_loop
method check's if the bricks have all been removed (in which case the player has won) or if the ball has fallen off the screen. If the ball has fallen off the screen and the player is out of lives then it ends the game, otherwise it decrements the players remaining lives. If there are still bricks and the ball is on the screen then it calls the ball's update
method to move it.
The frame.after
method sets a timer that will call the callback function you pass in after the delay (in milliseconds) that you pass in has expired.
def check_collisions(self):
"""checks if the ball has collided with anything"""
ball = self.ball.position
items = self.canvas.find_overlapping(*ball)
collisions = [self.collidable[item] for item in items
if item in self.collidable]
self.ball.collide(collisions)
return
check_collisions
finds all the items that we added whose coordinates overlap with those of the ball then passes those items to the BallWidget.collide
method to process. The overlapping widgets are filtered so that they only contain items of interest (not text-widgets, for instance).
def __call__(self):
"""sets up the game"""
self.set_up()
self.frame()
return
The Main Loop
if __name__ == '__main__':
frame_settings = (FrameSettings()
.window_width(600)
.window_height(400)
.canvas_color(WHITE)
.window_title("Breakout! Not Pong!")())
ball_settings = (BallSettings()
.x_position(10)
.y_position(10)
.circle_radius(10)
.direction_vector(BallDirections(horizontal=NO_MOVEMENT,
vertical=UP))
.velocity(10)
.color(LAVENDER)())
paddle_settings = (PaddleSettings()
.pixel_width(80)
.pixel_height(5)
.x_position(40)
.y_position(80)
.velocity(10)
.color(BLUE)
())
brick_settings = (BrickSettings()
.x_position(75)
.y_position(20)
.label("brick")
.pixel_width(75)
.pixel_height(20)
.level_colors({1: "#999999",
2: "#555555",
3: "#222222"})())
game_settings = (GameSettings()
.allowed_failures(3)
.text_horizontal_position(50)
.text_vertical_position(20)
.font_size(15)
.outer_padding(20)()
)
game = Game(game_settings, frame_settings, paddle_settings, brick_settings,
ball_settings)
game()