From ecbc857d809abdd5f6a1644200b7dd09d8932a36 Mon Sep 17 00:00:00 2001 From: Seppe De Loore Date: Sun, 6 Apr 2025 21:37:01 +0200 Subject: [PATCH] [refactor] Move objets to separate files. - ball - bricks - paddle Update documentation and readme. [fix] Game over: update player score. --- DOCUMENTATION.md | 14 +-- README.md | 2 +- ball.py | 54 ++++++++++ breakout.py | 251 ++++++----------------------------------------- bricks.py | 88 +++++++++++++++++ paddle.py | 52 ++++++++++ screen.py | 11 ++- 7 files changed, 241 insertions(+), 231 deletions(-) create mode 100644 ball.py create mode 100644 bricks.py create mode 100644 paddle.py diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 9a0ba3b..58f8433 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -6,16 +6,16 @@ This program is a simple game implemented in MicroPython for the Raspberry Pi Pi ## Framebuffer The program uses a framebuffer to draw the game graphics. The framebuffer is a 2D array that represents the pixels on the display. The program uses the micropyhton `framebuf` module to create and manipulate the framebuffer. The framebuffer is then copied to the display using the `blit` method. -## Multithreading +If the program crashes, the framebuffer is not always reset, causing the program to crash due to lack of memeory. +A hard reset is required to fix this issue. +## Multithreading The program uses multithreading to handle the game logic and the display updates separately. The `threading` module is used to create and manage the threads. The game logic is run in a separate thread from the display updates to ensure smooth gameplay and responsive controls. - ## Game Logic - The game logic is implemented in the `game_loop` function. This function runs in a separate thread and handles the following tasks: -- Updating the game state based on user input and game rules -- Generating new obstacles and updating their positions -- Checking for collisions between the character and obstacles +- Updating the game state based on user input andgame flow. +- Updating ball position, paddle position, and bricks on the screen. +- Checking for collisions between the ball, borders, paddle and bricks. - Updating the score based on the player's performance -- Sending the updated game state to the display thread for rendering +- Sending the updated game state to the display thread for rendering. diff --git a/README.md b/README.md index a5ca30e..8686916 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ This is a simple Breakout game implemented in MicroPython for the Raspberry Pi P 3. Clone or download this repository to your local machine. 4. Open the Thonny IDE and connect to your Raspberry Pi Pico. 5. Copy the contents of the repository to the Pico's file system. You can do this by dragging and dropping the files from your local machine to the Thonny file explorer. -6. Once the files are copied, reset the Pico to start the game. +6. Once the files are copied, reset the Pico to start the game (main.py will run automatically and start breakout.py). ## .env file In the .env file the user can control the behaviour of the joystick B-button. diff --git a/ball.py b/ball.py new file mode 100644 index 0000000..e26f7c8 --- /dev/null +++ b/ball.py @@ -0,0 +1,54 @@ +from random import randint + +from screen import Screen, WHITE +from paddle import Paddle + +BALL_SPEED = 3 + + +class Ball: + def __init__(self, screen: Screen, paddle: Paddle, radius: int, color: int = WHITE, brick_padding: int = 0, speed: int = BALL_SPEED): + self.screen_width = screen.width + self.screen_height = screen.height + self.radius = radius + self.color = color + self.speed = speed + self.x_speed = self.speed if randint(0, 1) == 0 else -self.speed + self.y_speed = -self.speed + self.brick_padding = brick_padding + self.reset_pos(paddle) + + def reset_pos(self, paddle: Paddle): + """Reset ball position to the center of the paddle.""" + self.x = self.screen_width // 2 + self.y = self.screen_height // 2 - self.radius - 2 + self.x_speed = self.speed if randint(0, 1) == 0 else -self.speed + self.y_speed = -self.speed + + def update_pos(self): + """Update ball position.""" + self.x += self.x_speed + self.y += self.y_speed + + # Bounce off left or right screen edge + if self.x < 0: + self.x = 0 + self.x_speed = -self.x_speed + elif self.x > self.screen_width - self.radius: + self.x = self.screen_width - self.radius + self.x_speed = -self.x_speed + + # Bounce off top screen edge + if self.y < self.brick_padding + self.radius: + self.y = self.brick_padding + self.radius + self.y_speed = -self.y_speed + + # Drop through bottom screen edge & return True to indicate we lose a life + if self.y > self.screen_height: + self.y = self.screen_height + self.y_speed = -self.y_speed + return True + + def draw(self, screen: Screen): + """Draw ball.""" + screen.fbuf.ellipse(self.x, self.y, self.radius, self.radius, self.color, True) diff --git a/breakout.py b/breakout.py index d4d279a..282baaa 100644 --- a/breakout.py +++ b/breakout.py @@ -1,12 +1,15 @@ """ -Threaded breakout game with frame buffer +Threaded breakout game using frame buffer -Author: Seppe De Loore +Author: Seppe De Loore - 2025 """ from random import randint from utime import sleep_us from screen import Screen, RED, YELLOW, GREEN, WHITE +from paddle import Paddle, PADDLE_WIDTH, PADDLE_HEIGHT +from ball import Ball +from bricks import BrickRow, create_bricks, BRICK_PADDING import _thread from joystick import Joystick @@ -19,19 +22,6 @@ SCREEN_HEIGHT = 135 SCREEN_WIDTH = 240 SCREEN_ROTATION = 1 # Landscape mode -PADDLE_WIDTH = 70 -PADDLE_HEIGHT = 10 -PADDLE_COLOR = WHITE -PADDLE_SPEED = 10 - -BRICK_WIDTH = 30 -BRICK_HEIGHT = 8 -BRICK_PADDING = 4 -BRICKS_PER_ROW = 7 -ROWS = 4 - -BALL_SPEED = 3 - SPLASH_WIDTH = 8 SPLASH_HEIGHT = 5 SPLASH_PADDING = 2 @@ -45,9 +35,7 @@ GAME_NEXT_LEVEL = 4 DEBOUNCE = 300_000 -# ============================ # load environment variables -# ============================ try: DISABLE_B = 0 with open(".env", "r") as file: @@ -59,177 +47,6 @@ except: DISABLE_B = 0 -# ============================ -# CLASSES -# ============================ - -class Paddle: - def __init__(self, screen: Screen): - """Initialize the paddle.""" - self.screen_width = screen.width - self.screen_height = screen.height - self.x = (self.screen_width - PADDLE_WIDTH) // 2 - self.y = self.screen_height - PADDLE_HEIGHT - 5 - self.width = PADDLE_WIDTH - self.height = PADDLE_HEIGHT - self.speed = PADDLE_SPEED - - def move(self, direction: int): - """ - Move paddle left or right. - Args: direction: -1 for left, 1 for right - """ - self.x += self.speed * direction - if self.x < 0: - self.x = 0 - elif self.x > self.screen_width - self.width: - self.x = self.screen_width - self.width - - def draw(self, screen: Screen): - """Draw paddle.""" - screen.fbuf.fill_rect(self.x, self.y, self.width, self.height, PADDLE_COLOR) - - def update(self, screen: Screen, joystick: Joystick): - """Update paddle position.""" - if joystick.joy_left() == 0: - self.move(-1) - elif joystick.joy_right() == 0: - self.move(1) - self.draw(screen) - - def hit(self, ball: Ball) -> bool: - """Check if the ball hits the paddle and adjust its position.""" - if ( - self.x <= ball.x <= self.x + self.width - and self.y <= ball.y + ball.radius <= self.y + self.height - ): - # Adjust the ball's position to be just above the paddle - ball.y = self.y - ball.radius - 2 - return True - return False - - -class Ball: - def __init__(self, screen: Screen, paddle: Paddle, radius: int, color: int): - """ - Initialize the ball. - - Args: - paddle (Paddle): The paddle object to position the ball on. - radius (int): Radius of the ball. - color (int): RGB565 color value of the ball. - """ - self.screen_width = screen.width - self.screen_height = screen.height - self.radius = radius - self.color = color - self.reset_pos(paddle) - self.x_speed = BALL_SPEED if randint(0, 1) == 0 else -BALL_SPEED - self.y_speed = -BALL_SPEED - - # Position the ball in the middle of the paddle - self.x = paddle.x + (paddle.width // 2) - self.y = paddle.y - radius - 2 # Place the ball just above the paddle - - def reset_pos(self, paddle: Paddle): - """Reset ball position to the center of the paddle. - Args: Paddle: The paddle object to position the ball on. - """ - self.x = self.screen_width // 2 - self.y = self.screen_height // 2 - self.radius - 2 - self.x_speed = BALL_SPEED - self.y_speed = -BALL_SPEED - - def update_pos(self): - """Update ball position.""" - self.x += self.x_speed - self.y += self.y_speed - - # Bounce off left or right screen edge - if self.x < 0: - self.x = 0 - self.x_speed = -self.x_speed - elif self.x > self.screen_width - self.radius: - self.x = self.screen_width - self.radius - self.x_speed = -self.x_speed - - # Bounce off top screen edge - if self.y < BRICK_PADDING + self.radius: - self.y = BRICK_PADDING + self.radius - self.y_speed = -self.y_speed - - # Drop through bottom screen edge & return True to indicate we lose a life - if self.y > self.screen_height: - self.y = self.screen_height - self.y_speed = -self.y_speed - return True - - def draw(self, screen: Screen): - """Draw ball.""" - screen.fbuf.ellipse(self.x, self.y, self.radius, self.radius, self.color, True) - - -class Brick: - def __init__(self, x: int, y: int, width: int, height: int, color: int): - """ - Initialize a brick. - Args: x (int): x-coordinate of the brick. - y (int): y-coordinate of the brick. - width (int): width of the brick. - height (int): height of the brick. - color (int): color of the brick. - """ - self.x = x - self.y = y - self.width = width - self.height = height - self.color = color - - def draw(self, screen: Screen): - """Draw brick.""" - screen.fbuf.fill_rect(self.x, self.y, self.width, self.height, self.color) - - -class BrickRow: - def __init__(self, brick_width: int, brick_height: int, padding:int, offset_top: int, color: int): - """ - Initialize a row of bricks. - Args: brick_width (int): width of each brick. - brick_height (int): height of each brick. - padding (int): padding between bricks. - offset_top (int): y-coordinate of the top of the row. - color (int): color of the bricks. - """ - self.brick_width = brick_width - self.brick_height = brick_height - self.color = color - self.padding = padding - self.offset_top = offset_top - self.bricks = [Brick(padding + i * (brick_width + padding), offset_top, brick_width, brick_height, color) for i in range(BRICKS_PER_ROW)] - self.brick_x = [padding + i * (brick_width + padding) for i in range(BRICKS_PER_ROW)] - self.brick_y = [offset_top] * BRICKS_PER_ROW - - def draw(self, screen: Screen): - """Draw all bricks in the row.""" - for brick in self.bricks: - if brick is not None: - brick.draw(screen) - - def hit(self, ball: Ball) -> bool: - """ - Check if the ball hits any brick in the row and remove it if hit. - Args: ball (Ball): The ball object to check for collision. - Returns: bool: True if the ball hits a brick, False otherwise. - """ - for i, brick in enumerate(self.bricks): - if brick is not None: - if (brick.x <= ball.x <= brick.x + brick.width) and (brick.y <= ball.y <= brick.y + brick.height): - # Remove the brick by setting it to None - self.bricks[i] = None - return True - return False - - class High_score: def __init__(self): self.high_score = 0 @@ -298,32 +115,19 @@ def splash_screen(screen: Screen, data_rows: list[int], text: list[str], high_sc screen.display.blit_buffer(screen.buffer, 0, 0, screen.buffer_width, screen.buffer_height) -def create_bricks() -> list[BrickRow]: - bricks = [] - for row in range(ROWS): - if row == 0: - color = RED - elif row == 1: - color = YELLOW - else: - color = GREEN - bricks.append( - BrickRow(BRICK_WIDTH, - BRICK_HEIGHT, - BRICK_PADDING, - 10 + row * (BRICK_HEIGHT + BRICK_PADDING), - color)) - return bricks - - def create_lives(screen: Screen, paddle: Paddle, lives: int) -> list[Ball]: """ - Create a list of small balls to represent lives - Args: lives (int): Number of lives left + Create a list of small balls to represent lives. + Args: + screen (Screen): The screen object. + paddle (Paddle): The paddle object. + lives (int): Number of lives left. + Returns: + list[Ball]: List of Ball objects representing lives. """ lives_balls = [] for i in range(0, lives): - life_ball = Ball(screen, paddle, radius=3, color=WHITE) + life_ball = Ball(screen, paddle, radius=3, color=WHITE, brick_padding=BRICK_PADDING) life_ball.x = 5 + (i - 1) * 7 life_ball.y = 7 life_ball.x_speed = 0 @@ -350,7 +154,7 @@ def main_loop(screen, joystick): level = 1 ball_stuck = True # Reset ball to be stuck to the paddle # Initialize paddle and ball - ball = Ball(screen, paddle, radius=5, color=WHITE) + ball = Ball(screen, paddle, radius=5, color=WHITE, brick_padding=BRICK_PADDING) paddle.width = PADDLE_WIDTH paddle.height = PADDLE_HEIGHT @@ -376,8 +180,8 @@ def main_loop(screen, joystick): # Launch the ball when "A" is pressed if joystick.button_a() == 0: ball_stuck = False - ball.y_speed = -BALL_SPEED - ball.x_speed = BALL_SPEED if randint(0, 1) == 0 else -BALL_SPEED + ball.y_speed = -ball.speed + ball.x_speed = ball.speed if randint(0, 1) == 0 else -ball.speed else: if ball.update_pos(): # If ball is out, lose a life and reset ball lives -= 1 @@ -413,6 +217,7 @@ def main_loop(screen, joystick): game_state = GAME_NEXT_LEVEL # Transition to next level if game_state == GAME_OVER: # Game over screen + current_score += score high_score.update_high_score(current_score) splash_screen( screen, @@ -444,8 +249,8 @@ def main_loop(screen, joystick): lives += 1 lives_balls = create_lives(screen, paddle, lives) score = 0 - ball_stuck = True # Ball is stuck again - sleep_us(DEBOUNCE) # Debounce delay + ball_stuck = True + sleep_us(DEBOUNCE) if DISABLE_B == 0 and joystick.button_b() == 0: break @@ -455,11 +260,13 @@ def main_loop(screen, joystick): if __name__ == "__main__": - joystick = Joystick() - screen = Screen(SCREEN_WIDTH, SCREEN_HEIGHT, SCREEN_ROTATION) - main_loop(screen, joystick) - - # Clean up - screen.clear() - buffer = None - fbuf = None + try: + joystick = Joystick() + screen = Screen(SCREEN_WIDTH, SCREEN_HEIGHT, SCREEN_ROTATION) + main_loop(screen, joystick) + except KeyboardInterrupt: + pass + finally: + # Clean up resources + screen.clear() + screen.cleanup() diff --git a/bricks.py b/bricks.py new file mode 100644 index 0000000..d05e545 --- /dev/null +++ b/bricks.py @@ -0,0 +1,88 @@ +from screen import Screen, RED, YELLOW, GREEN +from ball import Ball + +BRICK_WIDTH = 30 +BRICK_HEIGHT = 8 +BRICK_PADDING = 4 +BRICKS_PER_ROW = 7 +ROWS = 4 + + +class Brick: + def __init__(self, x: int, y: int, width: int, height: int, color: int): + """ + Initialize a brick. + Args: x (int): x-coordinate of the brick. + y (int): y-coordinate of the brick. + width (int): width of the brick. + height (int): height of the brick. + color (int): color of the brick. + """ + self.x = x + self.y = y + self.width = width + self.height = height + self.color = color + + def draw(self, screen: Screen): + """Draw brick.""" + screen.fbuf.fill_rect(self.x, self.y, self.width, self.height, self.color) + + +class BrickRow: + def __init__(self, brick_width: int, brick_height: int, padding:int, offset_top: int, color: int): + """ + Initialize a row of bricks. + Args: brick_width (int): width of each brick. + brick_height (int): height of each brick. + padding (int): padding between bricks. + offset_top (int): y-coordinate of the top of the row. + color (int): color of the bricks. + """ + self.brick_width = brick_width + self.brick_height = brick_height + self.color = color + self.padding = padding + self.offset_top = offset_top + self.bricks = [Brick(padding + i * (brick_width + padding), offset_top, brick_width, brick_height, color) for i in range(BRICKS_PER_ROW)] + self.brick_x = [padding + i * (brick_width + padding) for i in range(BRICKS_PER_ROW)] + self.brick_y = [offset_top] * BRICKS_PER_ROW + + def draw(self, screen: Screen): + """Draw all bricks in the row.""" + for brick in self.bricks: + if brick is not None: + brick.draw(screen) + + def hit(self, ball: Ball) -> bool: + """ + Check if the ball hits any brick in the row and remove it if hit. + Args: ball (Ball): The ball object to check for collision. + Returns: bool: True if the ball hits a brick, False otherwise. + """ + for i, brick in enumerate(self.bricks): + if brick is not None: + if (brick.x <= ball.x <= brick.x + brick.width) and (brick.y <= ball.y <= brick.y + brick.height): + # Remove the brick by setting it to None + self.bricks[i] = None + return True + return False + + + +def create_bricks() -> list[BrickRow]: + bricks = [] + for row in range(ROWS): + if row == 0: + color = RED + elif row == 1: + color = YELLOW + else: + color = GREEN + bricks.append( + BrickRow(BRICK_WIDTH, + BRICK_HEIGHT, + BRICK_PADDING, + 10 + row * (BRICK_HEIGHT + BRICK_PADDING), + color)) + return bricks diff --git a/paddle.py b/paddle.py new file mode 100644 index 0000000..9b3255c --- /dev/null +++ b/paddle.py @@ -0,0 +1,52 @@ +from screen import Screen, WHITE + +PADDLE_WIDTH = 70 +PADDLE_HEIGHT = 10 +PADDLE_COLOR = WHITE +PADDLE_SPEED = 10 + + +class Paddle: + def __init__(self, screen: Screen): + """Initialize the paddle.""" + self.screen_width = screen.width + self.screen_height = screen.height + self.x = (self.screen_width - PADDLE_WIDTH) // 2 + self.y = self.screen_height - PADDLE_HEIGHT - 5 + self.width = PADDLE_WIDTH + self.height = PADDLE_HEIGHT + self.speed = PADDLE_SPEED + + def move(self, direction: int): + """ + Move paddle left or right. + Args: direction: -1 for left, 1 for right + """ + self.x += self.speed * direction + if self.x < 0: + self.x = 0 + elif self.x > self.screen_width - self.width: + self.x = self.screen_width - self.width + + def draw(self, screen: Screen): + """Draw paddle.""" + screen.fbuf.fill_rect(self.x, self.y, self.width, self.height, PADDLE_COLOR) + + def update(self, screen: Screen, joystick: Joystick): + """Update paddle position.""" + if joystick.joy_left() == 0: + self.move(-1) + elif joystick.joy_right() == 0: + self.move(1) + self.draw(screen) + + def hit(self, ball: Ball) -> bool: + """Check if the ball hits the paddle and adjust its position.""" + if ( + self.x <= ball.x <= self.x + self.width + and self.y <= ball.y + ball.radius <= self.y + self.height + ): + # Adjust the ball's position to be just above the paddle + ball.y = self.y - ball.radius - 2 + return True + return False diff --git a/screen.py b/screen.py index 0862cf0..0932adc 100644 --- a/screen.py +++ b/screen.py @@ -2,7 +2,7 @@ from machine import Pin, SPI import _thread from st7789 import ST7789 from framebuf import FrameBuffer, RGB565 - +import gc def color565(red: int, green: int, blue: int) -> int: @@ -55,6 +55,8 @@ class Screen: self.display.blit_buffer(self.buffer, 0, 0, self.buffer_width, self.buffer_height) def clear(self, refresh: bool = True): + if self.fbuf is None: + return self.fbuf.fill(BLACK) if refresh: self.refresh() @@ -65,3 +67,10 @@ class Screen: self.fbuf.fill(BLACK) self.render_frame = False # thread will exit and self clean removing need for garbage collection + + def cleanup(self): + """Free resources used by the framebuffer and SPI.""" + self.buffer = None + self.fbuf = None + self.spi.deinit() + gc.collect()