7
\$\begingroup\$

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
asked Mar 21, 2024 at 15:32
\$\endgroup\$

1 Answer 1

10
\$\begingroup\$

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.

  1. Combine pairs of args to form a single (width, height) tuple arg.
  2. Explicitly name e.g. ... , offset_from_top=2) at the call site.
  3. Put kw default argument value in the signature, e.g. offset_from_top=2, and then caller is free to omit it.
  4. 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.

answered Mar 21, 2024 at 17:01
\$\endgroup\$
2
  • \$\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 of tuple. 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\$ Commented 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 a venv to run a suitable interpreter. (2.) Let's accept the premise that we're targeting a "weird" / downrev interpreter. Then I find assert 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\$ Commented Mar 21, 2024 at 23:36

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.