I came from C# background with strong OOP approach. I've decided to learn Python and Breakout game is a way to cover the basics of Python.
I'll be really glad for any feedback and general opinion about my coding style and me using Python.
Game also on github: https://github.com/bartex-bartex/BreakoutClone
Running game for visualization: https://imgur.com/a/CWQwyhD
run.py
from curses import wrapper
from game import Game
def main():
game = Game()
wrapper(game.start)
if __name__ == '__main__':
main()
game.py
from time import sleep
from typing import Tuple
import curses
import helpers.helpers as helper
from board import Board
from sprite import Sprite
from ball import Ball
from tile import Tile
from helpers.rectangle import Rectangle
class Game:
_WELCOME_MESSAGE = "Press SPACE to begin!"
_FRAME_TIME = 1 / 10
def __init__(self) -> None:
self.score = 0
def _arrange_tiles(self, rows_count, tile_width, tile_height, space_between_tiles, map_width, map_height, offset_from_left, offset_from_top):
tiles = []
n = int((map_width - 2 - offset_from_left) / (tile_width + space_between_tiles)) # number of tiles in row
for i in range(rows_count):
ith_row_tiles = []
for j in range(n):
# tile upper-left corner coordination
tile_x = 1 + offset_from_left + j * (tile_width + 2)
tile_y = 1 + offset_from_top + i * (tile_height + 1)
# leave some space between player and tiles
if tile_y > map_height - 8:
break
ith_row_tiles.append(Tile(Rectangle(tile_x, tile_y, tile_width, tile_height), i))
tiles.append(ith_row_tiles)
return tiles
def get_tiles_amount_left(self) -> int:
not_broken = 0
for tile_row in self.tiles:
for tile in tile_row:
if tile.is_broken == False:
not_broken += 1
return not_broken
def _setup_curses(self, screen) -> Tuple[int, int]:
curses.curs_set(False) # do not show cursor
y, x = screen.getmaxyx() # get terminal wymiary
screen.resize(y, x) # resize "virtual" terminal to original terminal
return x, y
def _initialize_game_objects(self, x, y):
board = Board(x, y)
sprite = Sprite(x, y)
ball = Ball(sprite)
self.tiles = self._arrange_tiles(3, 5, 1, 2, x, y, 3, 2)
self.begin_tiles_count = self.get_tiles_amount_left()
return board, sprite, ball
def _draw_map(self, screen, board, ball, sprite, tiles = None, redraw_border = False):
board.draw_ball(screen, ball)
board.draw_sprite(screen, sprite)
# In theory, border should be drawn only at the beginning
if redraw_border == True:
board.draw_border(screen)
# None -> nothing to update
if tiles != None:
board.draw_tiles(tiles, screen)
def _wait_for_user_start(self, screen, x, y):
helper.display_center(self._WELCOME_MESSAGE, y - 2, screen, x)
key = None
while key != ord(' '):
key = screen.getch()
helper.display_center(' ' * len(self._WELCOME_MESSAGE), y - 2, screen, x)
screen.nodelay(True) # .getch() doesn't wait for key
def _handle_user_input(self, screen, board, sprite):
key = screen.getch()
curses.flushinp()
if key == curses.KEY_RIGHT:
sprite.move_right(board.width)
return False
elif key == curses.KEY_LEFT:
sprite.move_left()
return False
elif key == ord('q'):
return True
def _display_win_message(self, screen, x, y):
helper.display_center('You win!', int(y / 2), screen, x)
helper.display_center(f'Your score is {self.score}', int(y / 2) + 1, screen, x)
helper.display_center("Press 'q' to leave", int(y / 2) + 2, screen, x)
def _display_lose_message(self, screen, x, y):
helper.display_center('You lose...', int(y / 2), screen, x)
helper.display_center(f'Your score is {self.score}', int(y / 2) + 1, screen, x)
helper.display_center("Press 'q' to leave", int(y / 2) + 2, screen, x)
def _wait_for_user_exit(self, screen):
screen.nodelay(False)
while True:
key = screen.getch()
if key == ord('q'):
break
def start(self, screen):
x, y = self._setup_curses(screen)
board, sprite, ball = self._initialize_game_objects(x, y)
self._draw_map(screen, board, ball, sprite, self.tiles, True)
self._wait_for_user_start(screen, x, y)
update_tiles = False
while True:
if update_tiles == True:
self._draw_map(screen, board, ball, sprite, self.tiles)
else:
self._draw_map(screen, board, ball, sprite)
quit_game = self._handle_user_input(screen, board, sprite)
if quit_game == True:
break
continue_game, update_tiles = ball.update(sprite, self.tiles, x, y, self)
if continue_game == False:
self._display_lose_message(screen, x, y)
break
elif self.begin_tiles_count == self.score:
self._display_win_message(screen, x, y)
break
sleep(self._FRAME_TIME)
self._wait_for_user_exit(screen)
board.py
from typing import List
import curses
from enums.colors_enum import Color
from enums.directions_enum import Direction
from tile import Tile
class Board:
def __init__(self, width, height) -> None:
self.width = width
self.height = height
self._prepare_colors()
def _prepare_colors(self):
curses.use_default_colors() # Thanks to that -1 is transparent
curses.init_pair(Color.SPRITE.value, curses.COLOR_RED, -1) # Sprite
curses.init_pair(Color.BALL.value, curses.COLOR_BLUE, -1) # Ball
curses.init_pair(Color.TILE.value, curses.COLOR_GREEN, -1) # Tile
def draw_border(self, screen):
screen.border('|', '|', '-', '-', '+', '+', '+', '+')
def draw_sprite(self, screen, sprite):
screen.addstr(sprite.y, sprite.x, "█" * sprite.length, curses.color_pair(Color.SPRITE.value))
if sprite.current_direction == Direction.LEFT:
# remove most right sprite cells
screen.addstr(sprite.y, sprite.x + sprite.length, ' ' * sprite.movement_speed)
elif sprite.current_direction == Direction.RIGHT:
# remove most left sprite cells
screen.addstr(sprite.y, sprite.x - sprite.movement_speed, ' ' * sprite.movement_speed)
def draw_ball(self, screen, ball):
screen.addstr(ball.y - ball.move_vector[1], ball.x - ball.move_vector[0], " ")
screen.addstr(ball.y, ball.x, "⬤", curses.color_pair(Color.BALL.value))
def draw_tiles(self, tiles: List[List[Tile]], screen: curses.window):
for tile_row in tiles:
for tile in tile_row:
for i in range(tile.rect.height):
if tile.is_broken == False:
screen.addstr(tile.rect.y + i, tile.rect.x, "█" * tile.rect.width, curses.color_pair(Color.TILE.value))
else:
screen.addstr(tile.rect.y + i, tile.rect.x, " " * tile.rect.width)
sprite.py
import helpers.helpers as helper
from enums.directions_enum import Direction
class Sprite:
_DEFAULT_LENGTH = 6
_DEFAULT_MOVEMENT_SPEED = 2
def __init__(self, map_width, map_height) -> None:
self.length = self._DEFAULT_LENGTH
self.movement_speed = self._DEFAULT_MOVEMENT_SPEED
self.current_direction = None
self.x, self.y = self._calculate_start_position(map_width, map_height)
def _calculate_start_position(self, map_width, map_height):
x = helper.calculate_center(map_width, self.length)
y = map_height - 3
return (x, y)
def move_right(self, map_width):
if self.x + self.length < map_width - self.movement_speed:
self.current_direction = Direction.RIGHT
self.x += self.movement_speed
def move_left(self):
if self.x - 1 > self.movement_speed:
self.current_direction = Direction.LEFT
self.x -= self.movement_speed
ball.py
from typing import List
from typing import Tuple
import helpers.helpers as helpers
from sprite import Sprite
from tile import Tile
from helpers.rectangle import Rectangle
class Ball:
def __init__(self, sprite) -> None:
self._calculate_start_position(sprite)
self.move_vector = [-1, -1]
def _calculate_start_position(self, sprite: Sprite):
# Based ball position upon sprite position
self.x = sprite.x + helpers.calculate_center(sprite.length, 1)
self.y = sprite.y - 1
def update(self, sprite: Sprite, tiles: List[List[Tile]], map_width, map_height, game) -> Tuple[bool, bool]:
next_x, next_y = self.x + self.move_vector[0], self.y + self.move_vector[1]
updated_tile = False
if next_y == map_height - 1: # check for D boundary collision
return False, False
elif next_x == 0 or next_x == map_width - 1: # check for L, R boundary collision
self.move_vector = [self.move_vector[0] * -1, self.move_vector[1]]
elif next_y == 0: # check for U boundary collision
self.move_vector = [self.move_vector[0], self.move_vector[1] * -1]
elif helpers.is_collision(next_x, next_y, Rectangle(sprite.x, sprite.y, sprite.length, 1)): # check - sprite for collision
self.move_vector = [self.move_vector[0], self.move_vector[1] * -1]
else:
for tile_row in tiles:
for tile in tile_row:
if tile.is_broken == False and helpers.is_collision(next_x, next_y, tile.rect):
tile.is_broken = True
self.move_vector = [self.move_vector[0], self.move_vector[1] * -1]
game.score += 1
updated_tile = True
self.x += self.move_vector[0]
self.y += self.move_vector[1]
return True, updated_tile
tile.py
from helpers.rectangle import Rectangle
class Tile:
def __init__(self, rect: Rectangle, row: int) -> None:
self.rect = rect
self.row = row
self.is_broken = False
helpers.py
from helpers.rectangle import Rectangle
def is_collision(x, y, area: Rectangle) -> bool:
return (area.x <= x <= area.x + area.width and
area.y <= y <= area.y + area.height)
def display_center(text, row, screen, screen_width):
"""
:param str text: Text to display
:param int row: Display row, 1-based-index
:param window screen: Curses screen on which to display
:param int screen_width: The width of emulated screen
"""
x = calculate_center(screen_width, len(text))
screen.addstr(row, x, text)
def calculate_center(width, object_length = 1) -> int:
"""
Calculate x position at which the text should begin.
:param int width: Display screen width
:param int object_length: How long is displayed object
:return int: x position
"""
if object_length < 1:
raise Exception('Width should be >= 1')
if object_length == 1:
return int((width - 1) // 2)
else:
return int((width + 1) // 2 - (object_length // 2))
rectangle.py
class Rectangle:
def __init__(self, x, y, width, height):
self.x = x
self.y = y
self.width = width
self.height = height
colors_enum.py
from enum import Enum
class Color(Enum):
SPRITE = 1
BALL = 2
TILE = 3
directions_enum.py
from enum import Enum
class Direction(Enum):
RIGHT = 1
LEFT = 2
1 Answer 1
Wow, no import pygame
,
this is strictly a colorful curses project.
A bold design move!
The animated demo image looks great.
As does the source code.
old-style annotations
from typing import Tuple
...
def _setup_curses(self, screen) -> Tuple[int, int]:
I completely understand why you would write that, as it appears in lots of introductory materials and is a big improvement over what came before.
In modern usage we prefer to avoid using the typing
module,
especially for common containers like list
and tuple
.
So
def ... -> Tuple[int, int]:
becomes
def ... -> tuple[int, int]:
nit, typo: Last word in # tile upper-left corner coordination
should probably be coordinate
.
_arrange_tiles()
has some magic numbers, but they seem Fine
and probably aren't worth giving names to.
Shortening ith_row_tiles
to just row
would be OK.
negative names
if tile.is_broken == False:
not_broken += 1
In get_tiles_amount_left()
the negated identifier,
and the sense of .is_broken
rather than tile.exists
,
seem inconvenient.
Humans do better when reasoning about variables stated in the positive, rather than the negative.
In many languages, including python, booleans work like integers.
Consider using sum()
.
I will just note this example in passing:
>>> sum([True, False, True])
2
Also, even if you change nothing else, prefer to phrase this conditional as
if not tile.is_broken:
keyword args
self.tiles = self._arrange_tiles(3, 5, 1, 2, x, y, 3, 2)
I'm sure that is correct. It is not easy to read. There is ample opportunity for a maintenance engineer to accidentally mess it up while making an edit.
Consider using any of these techniques to simplify the call.
- Combine pairs of args to form a single
(width, height)
tuple arg. - Explicitly name e.g.
... , offset_from_top=2)
at the call site. - Put kw default argument value in the signature, e.g.
offset_from_top=2
, and then caller is free to omit it. - Define a new @dataclass which tracks some related dimensions, so we pass in fewer args.
helper design
def _initialize_game_objects(self, ...):
board = ...
sprite = ...
ball = ...
self.tiles = ...
self.begin_tiles_count = ...
return board, sprite, ball
I like your habitual use of helpers.
This is a well named helper.
But we invoke it for two reasons:
for side effects on self
, and for three game objects.
It's not exactly bad, but it's a slightly surprising style.
singleton comparison
_draw_map()
does this:
if tiles != None:
Prefer if tiles is not None
,
which is comparing addresses (comparing id(tiles)
against id(None)
)
instead of comparing content.
Better still, just use if tiles:
.
Python's truthiness rules
thankfully are pretty simple.
Later, in start()
, please replace
if update_tiles == True:
with a simple if update_tiles:
.
Both mypy
and humans can easily see the variable is of type bool
.
Feel free to add an annotation if you want to be even more explicit.
API design
if update_tiles == True:
self._draw_map(screen, board, ball, sprite, self.tiles)
else:
self._draw_map(screen, board, ball, sprite)
That seems a little weird. Recommend you just unconditionally ask for (board, ball, sprite) to be drawn, and then let the caller worry about conditionally drawing tiles if they exist.
dataclass
class Tile:
def __init__(self, rect: Rectangle, row: int) -> None:
self.rect = rect
self.row = row ...
This is just a @dataclass. Consider converting it to one, so it's slightly simpler.
Similarly for Rectangle
.
Also, I found area
slightly hard to read here:
def is_collision(x, y, area: Rectangle) -> bool:
I think of "area" as a scalar real-valued quantity,
as opposed e.g. to a region
, a shape
, a box
, or a rect
.
docstring begins with a sentence
The docstring for calculate_center()
is perfect.
It begins with an English sentence that gives its
single responsibility,
and goes on to explain the details.
The signature even reveals some of the typing details!
Consider using $ mypy --strict *.py
,
if you choose to spruce up some of those signatures.
(Full disclosure: I also tend to use a
@beartype class Foo:
decorator on some of my classes, for runtime checking.)
In contrast, I found the display_center()
docstring
kind of "meh", and I was sad to find no type annotations
in the signature. Your identifiers are great,
and with annotations there would be almost nothing
left for the docstring to say.
We could maybe rename screen
-> curses_screen
, IDK.
And the fact that row
is 1-origin is
a valuable remark to retain.
Currently we have a 4-line description of a 2-line helper, and
that's even after eliding the mandatory introductory sentence.
I love to see a helpful docstring,
but in this case it seems to be quicker to just read the code.
enum auto()
These are perfectly fine as-is:
class Color(Enum):
SPRITE = 1
BALL = 2
TILE = 3
...
class Direction(Enum):
RIGHT = 1
LEFT = 2
In cases where we only care about unique values,
assigning e.g. RIGHT = auto()
suffices.
In the particular case of sprite colors,
you might have assigned curses.COLOR_RED
to SPRITE.
In any event, no need for an _enum
suffix on these module names.
This application is coded in an admirably clear style and achieves its design goals.
I would be willing to delegate or accept maintenance tasks on it.
-
\$\begingroup\$ For what it's worth, I have Python 3.85 (I know, it's a bit old) and I have to use
typing.Tuple
instead oftuple
. Even if I had a later Python I would probably continue to do this if I am writing the code for others and want to achieve maximum portability. Do you have any thoughts on this? \$\endgroup\$Booboo– Booboo2024年03月21日 20:32:58 +00:00Commented Mar 21, 2024 at 20:32 -
\$\begingroup\$ @Booboo well, there's two things going on here. (1.) I tend to reject the premise, when people tell me that for business reasons they need to run /usr/bin/python-3.8.5 or similar downrev interpreter. Both business and engineering are full of tradeoffs, and I have always found a way for
conda
or avenv
to run a suitable interpreter. (2.) Let's accept the premise that we're targeting a "weird" / downrev interpreter. Then I findassert sys.version_info >= (3, 8)
is a useful way to signal such an intent. In general I'm more inclined to trust asserts over comments. BTW, October 2024 is EOL. \$\endgroup\$J_H– J_H2024年03月21日 23:36:12 +00:00Commented Mar 21, 2024 at 23:36