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
\$\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.

\$\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\$
    – Booboo
    Mar 21 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\$
    – J_H
    Mar 21 at 23:36

Your Answer

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

Not the answer you're looking for? Browse other questions tagged or ask your own question.