commit aa96f89a131bd199284df99c710c8041c92fcece Author: Seppe De Loore Date: Thu Apr 3 06:57:23 2025 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ccd92f6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.vscode +.micropico \ No newline at end of file diff --git a/breakout.py b/breakout.py new file mode 100644 index 0000000..715146e --- /dev/null +++ b/breakout.py @@ -0,0 +1,409 @@ +""" +Threaded bouncing boxes with frame buffer + +Uses a single shot function for second core SPI handler. +This cleans itself when the function exits removing the +need for a garbage collection call. + +""" +from gc import collect +collect() + +# import libraries +import math +import array +from machine import Pin, SPI +import framebuf +from random import random, seed, randint +from utime import sleep_us, ticks_cpu, ticks_us +import _thread +import st7789 as st7789 +from joystick import Joystick + +# ============================ +# Helper Functions +# ============================ +def color565(r, g, b): + """Convert RGB888 to RGB565.""" + return (((g & 0b00011100) << 3) + ((r & 0b11111000) >> 3) << 8) + (b & 0b11111000) + ((g & 0b11100000) >> 5) + + +RED = color565(0, 0, 255) +GREEN = color565(0, 255, 0) +YELLOW = color565(0, 255, 255) +BLACK = color565(0, 0, 0) +WHITE = color565(255, 255, 255) + + +def clear_display(): + """Clear the display.""" + global fbuf, display, buffer, buffer_width, buffer_height + fbuf.fill(BLACK) + display.blit_buffer(buffer, 0, 0, buffer_width, buffer_height) + + +# ============================ +# Constants and Configuration +# ============================ +SCREEN_WIDTH = 135 +SCREEN_HEIGHT = 240 +SCREEN_ROTATION = 1 + +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 + + +# ============================ +# set up SPI and display +spi = SPI(1, + baudrate=31250000, + polarity=1, + phase=1, + bits=8, + firstbit=SPI.MSB, + sck=Pin(10), + mosi=Pin(11)) + +display = st7789.ST7789( + spi, + SCREEN_WIDTH, + SCREEN_HEIGHT, + reset=Pin(12, Pin.OUT), + cs=Pin(9, Pin.OUT), + dc=Pin(8, Pin.OUT), + backlight=Pin(13, Pin.OUT), + rotation=SCREEN_ROTATION) + + +# FrameBuffer needs 2 bytes for every RGB565 pixel +buffer_width = SCREEN_HEIGHT +buffer_height = SCREEN_WIDTH + 1 +buffer_height = 136 +buffer = bytearray(buffer_width * buffer_height * 2) +fbuf = framebuf.FrameBuffer(buffer, buffer_width, buffer_height, framebuf.RGB565) + +render_frame = False + +# ============================ +# CLASSES +# ============================ + +class Paddle: + def __init__(self): + self.x = (SCREEN_HEIGHT - PADDLE_WIDTH) // 2 + self.y = SCREEN_WIDTH - 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 > SCREEN_HEIGHT - self.width: + self.x = SCREEN_HEIGHT - self.width + + def draw(self): + """Draw paddle.""" + global fbuf + fbuf.fill_rect(self.x, self.y, self.width, self.height, PADDLE_COLOR) + + def update(self): + """Update paddle position.""" + global joystick + if joystick.joy_left() == 0: + self.move(-1) + elif joystick.joy_right() == 0: + self.move(1) + self.draw() + + def hit(self, ball: Ball) -> bool: + """Check if the ball hits the paddle.""" + return ( + self.x < ball.x < self.x + self.width + and self.y < ball.y < self.y + self.height) + + +class Ball: + def __init__(self, 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.radius = radius + self.color = color + self.reset_pos(paddle) + self.x_speed = 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 screen. + Args: Paddle: The paddle object to position the ball on. + """ + self.x = SCREEN_HEIGHT // 2 + self.y = SCREEN_WIDTH // 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 > SCREEN_HEIGHT: + self.x = SCREEN_HEIGHT + 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 > SCREEN_WIDTH: + self.y = SCREEN_WIDTH + self.y_speed = -self.y_speed + return True + + def draw(self): + """Draw ball.""" + global fbuf + 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): + """Draw brick.""" + global fbuf + 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): + global fbuf + for brick in self.bricks: + if brick is not None: + brick.draw() + + 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 splash_screen(data_rows: list[int]): + """ + Display a splash screen using the bits in the data_rows. + Args: data_rows (list[int]): List of hex values to display as blocks. + """ + global fbuf, buffer, buffer_width, buffer_height, joystick, render_frame + fbuf.fill(BLACK) + + start_x = 5 + start_y = 20 + + for row_index, hex_value in enumerate(data_rows): + binary = bin(hex_value)[2:] # Convert to binary + binary = '{:0>22}'.format(binary) # Pad to 22 columns + if 0 <= row_index <= 1: + color = RED + elif 2 <= row_index <= 4: + color = YELLOW + else: + color = GREEN + for bit_index, bit in enumerate(binary): + if bit == '1': # Only draw a block for '1' + x = start_x + bit_index * (SPLASH_WIDTH + SPLASH_PADDING) + y = start_y + row_index * (SPLASH_WIDTH + SPLASH_PADDING) + fbuf.fill_rect(x, y, SPLASH_WIDTH, SPLASH_HEIGHT, color) + fbuf.text("Press A to start", 5, 100, WHITE) + fbuf.text("Press B to exit", 5, 120, WHITE) + + # Wait for the frame to be rendered & update the display + while render_frame: + pass + display.blit_buffer(buffer, 0, 0, buffer_width, buffer_height) + + +def main_loop(): + global fbuf, buffer, buffer_width, buffer_height, joystick + global render_frame + + 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)) + score = 0 + lives = 3 + + paddle = Paddle() + ball = Ball(paddle, radius=5, color=WHITE) + # Create a list of small balls to represent lives + lives_balls = [] + for i in range(0, lives): + life_ball = Ball(paddle, radius=3, color=WHITE) + life_ball.x = 5 + (i - 1) * 7 + life_ball.y = 7 + life_ball.x_speed = 0 + lives_balls.append(life_ball) + + render_frame = False + state = 0 # 0 = start screen, 1 = game, 2 = game over, 3 = game win + + try: + while True: + if state == 0: # Startup screen + splash_screen([0x060046, 0x056B54, 0x054A64, 0x064A46, 0x054A62, 0x054A52, 0x074B56]) + + if joystick.button_a() == 0: # Transition to game state when A is pressed + state = 1 + lives = 3 + score = 0 + + elif state == 1 and lives > 0 and score < 28: # Game state + paddle.update() + if ball.update_pos(): # If ball is out of bounds, lose a life and reset ball position + lives -= 1 + lives_balls.pop() + ball.reset_pos(paddle) # Reset ball position to the center of the screen + + if paddle.hit(ball): + ball.y_speed = -ball.y_speed + for row in bricks: + if row.hit(ball): + ball.y_speed = -ball.y_speed + score += 1 + break + for i in range(1, len(lives_balls)): + lives_balls[i].draw() + for row in bricks: + row.draw() + ball.draw() + paddle.draw() + + while render_frame: + pass + render_frame = True + # Start SPI handler on core 1 + spi_thread = _thread.start_new_thread(render_thread, ()) + + if state == 1 and (lives == 0 or score == 28): # Game over or win: + if lives > 0: + state= 3 # Winning state + else: + state = 2 # Losing state + if state == 2: # Game over screen + splash_screen([0x0276DC, 0x025490, 0x025494, 0x0256DC, 0x025298, 0x025294, 0x0376D4]) + + if state == 3: # Game win screen + splash_screen([0x04548, 0x04548, 0x04568, 0x05578, 0x05558, 0x05548, 0x03948]) + + if state != 1 and joystick.button_a() == 0: # Transition to start state when A is pressed + state = 0 + sleep_us(1_000_000) # Debounce delay + + if joystick.button_b() == 0: # Exit game when B is pressed + break + except KeyboardInterrupt: + pass + + +def render_thread(): + global fbuf, buffer, buffer_width, buffer_height, render_frame, spi + global display, SCREEN_WIDTH, SCREEN_HEIGHT, SCREEN_ROTATION + + display.blit_buffer(buffer, 0, 0, buffer_width, buffer_height) + fbuf.fill(0) + + render_frame = False + # thread will exit and self clean removing need for garbage collection + + +if __name__ == "__main__": + joystick = Joystick() + main_loop() + + # Clean up + clear_display() + buffer = None + fbuf = None diff --git a/joystick.py b/joystick.py new file mode 100644 index 0000000..1416957 --- /dev/null +++ b/joystick.py @@ -0,0 +1,12 @@ +from machine import Pin +class Joystick: + def __init__(self): + # Map buttons + self.button_a = Pin(15, Pin.IN, Pin.PULL_UP) + self.button_b = Pin(17, Pin.IN, Pin.PULL_UP) + # Map joystick + self.joy_up = Pin(2,Pin.IN, Pin.PULL_UP) + self.joy_down = Pin(18,Pin.IN, Pin.PULL_UP) + self.joy_left = Pin(16 ,Pin.IN, Pin.PULL_UP) + self.joy_right = Pin(20 ,Pin.IN, Pin.PULL_UP) + self.joy_click = Pin(3, Pin.IN, Pin.PULL_UP) diff --git a/main.py b/main.py new file mode 100644 index 0000000..a93e5d5 --- /dev/null +++ b/main.py @@ -0,0 +1 @@ +exec(open("breakout.py").read(), globals()) diff --git a/st7789.py b/st7789.py new file mode 100644 index 0000000..52408c3 --- /dev/null +++ b/st7789.py @@ -0,0 +1,377 @@ +""" +Copyright (c) 2020, 2021 Russ Hughes + +This file incorporates work covered by the following copyright and +permission notice and is licensed under the same terms: + +The MIT License (MIT) + +Copyright (c) 2019 Ivan Belokobylskiy + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +The driver is based on devbis' st7789py_mpy module from +https://github.com/devbis/st7789py_mpy. + +This driver adds support for: + +- 320x240, 240x240 and 135x240 pixel displays +- Display rotation +- Hardware based scrolling +- Drawing text using 8 and 16 bit wide bitmap fonts with heights that are + multiples of 8. Included are 12 bitmap fonts derived from classic pc + BIOS text mode fonts. +- Drawing text using converted TrueType fonts. +- Drawing converted bitmaps + +""" + +import time +from micropython import const +import ustruct as struct + +# commands +ST7789_NOP = const(0x00) +ST7789_SWRESET = const(0x01) +ST7789_RDDID = const(0x04) +ST7789_RDDST = const(0x09) + +ST7789_SLPIN = const(0x10) +ST7789_SLPOUT = const(0x11) +ST7789_PTLON = const(0x12) +ST7789_NORON = const(0x13) + +ST7789_INVOFF = const(0x20) +ST7789_INVON = const(0x21) +ST7789_DISPOFF = const(0x28) +ST7789_DISPON = const(0x29) +ST7789_CASET = const(0x2A) +ST7789_RASET = const(0x2B) +ST7789_RAMWR = const(0x2C) +ST7789_RAMRD = const(0x2E) + +ST7789_PTLAR = const(0x30) +ST7789_VSCRDEF = const(0x33) +ST7789_COLMOD = const(0x3A) +ST7789_MADCTL = const(0x36) +ST7789_VSCSAD = const(0x37) + +ST7789_MADCTL_MY = const(0x80) +ST7789_MADCTL_MX = const(0x40) +ST7789_MADCTL_MV = const(0x20) +ST7789_MADCTL_ML = const(0x10) +ST7789_MADCTL_BGR = const(0x08) +ST7789_MADCTL_MH = const(0x04) +ST7789_MADCTL_RGB = const(0x00) + +ST7789_RDID1 = const(0xDA) +ST7789_RDID2 = const(0xDB) +ST7789_RDID3 = const(0xDC) +ST7789_RDID4 = const(0xDD) + +COLOR_MODE_65K = const(0x50) +COLOR_MODE_262K = const(0x60) +COLOR_MODE_12BIT = const(0x03) +COLOR_MODE_16BIT = const(0x05) +COLOR_MODE_18BIT = const(0x06) +COLOR_MODE_16M = const(0x07) + +# Color definitions +BLACK = const(0x0000) +BLUE = const(0x001F) +RED = const(0xF800) +GREEN = const(0x07E0) +CYAN = const(0x07FF) +MAGENTA = const(0xF81F) +YELLOW = const(0xFFE0) +WHITE = const(0xFFFF) + +_ENCODE_PIXEL = ">H" +_ENCODE_POS = ">HH" +_DECODE_PIXEL = ">BBB" + +_BUFFER_SIZE = const(256) + +_BIT7 = const(0x80) +_BIT6 = const(0x40) +_BIT5 = const(0x20) +_BIT4 = const(0x10) +_BIT3 = const(0x08) +_BIT2 = const(0x04) +_BIT1 = const(0x02) +_BIT0 = const(0x01) + +# Rotation tables (width, height, xstart, ystart)[rotation % 4] + +WIDTH_320 = [(240, 320, 0, 0), + (320, 240, 0, 0), + (240, 320, 0, 0), + (320, 240, 0, 0)] + +WIDTH_240 = [(240, 240, 0, 0), + (240, 240, 0, 0), + (240, 240, 0, 80), + (240, 240, 80, 0)] + +WIDTH_135 = [(135, 240, 52, 40), + (240, 135, 40, 53), + (135, 240, 53, 40), + (240, 135, 40, 52)] + +# MADCTL ROTATIONS[rotation % 4] +ROTATIONS = [0x00, 0x60, 0xc0, 0xa0] + + +def color565(red, green=0, blue=0): + """ + Convert red, green and blue values (0-255) into a 16-bit 565 encoding. + """ + try: + red, green, blue = red # see if the first var is a tuple/list + except TypeError: + pass + return (red & 0xf8) << 8 | (green & 0xfc) << 3 | blue >> 3 + + +def _encode_pos(x, y): + """Encode a postion into bytes.""" + return struct.pack(_ENCODE_POS, x, y) + + +def _encode_pixel(color): + """Encode a pixel color into bytes.""" + return struct.pack(_ENCODE_PIXEL, color) + + +class ST7789(): + """ + ST7789 driver class + + Args: + spi (spi): spi object **Required** + width (int): display width **Required** + height (int): display height **Required** + reset (pin): reset pin + dc (pin): dc pin **Required** + cs (pin): cs pin + backlight(pin): backlight pin + rotation (int): display rotation + - 0-Portrait + - 1-Landscape + - 2-Inverted Portrait + - 3-Inverted Landscape + """ + def __init__(self, spi, width, height, reset=None, dc=None, + cs=None, backlight=None, rotation=0): + """ + Initialize display. + """ + if height != 240 or width not in [320, 240, 135]: + raise ValueError( + "Unsupported display. 320x240, 240x240 and 135x240 are supported." + ) + + if dc is None: + raise ValueError("dc pin is required.") + + self._display_width = self.width = width + self._display_height = self.height = height + self.xstart = 0 + self.ystart = 0 + self.spi = spi + self.reset = reset + self.dc = dc + self.cs = cs + self.backlight = backlight + self._rotation = rotation % 4 + + self.hard_reset() + self.soft_reset() + self.sleep_mode(False) + + self._set_color_mode(COLOR_MODE_65K | COLOR_MODE_16BIT) + time.sleep_ms(50) + self.rotation(self._rotation) + self.inversion_mode(True) + time.sleep_ms(10) + self._write(ST7789_NORON) + time.sleep_ms(10) + if backlight is not None: + backlight.value(1) + self._write(ST7789_DISPON) + time.sleep_ms(500) + + def _write(self, command=None, data=None): + """SPI write to the device: commands and data.""" + if self.cs: + self.cs.off() + + if command is not None: + self.dc.off() + self.spi.write(bytes([command])) + if data is not None: + self.dc.on() + self.spi.write(data) + if self.cs: + self.cs.on() + + def hard_reset(self): + """ + Hard reset display. + """ + if self.cs: + self.cs.off() + if self.reset: + self.reset.on() + time.sleep_ms(50) + if self.reset: + self.reset.off() + time.sleep_ms(50) + if self.reset: + self.reset.on() + time.sleep_ms(150) + if self.cs: + self.cs.on() + + def soft_reset(self): + """ + Soft reset display. + """ + self._write(ST7789_SWRESET) + time.sleep_ms(150) + + def sleep_mode(self, value): + """ + Enable or disable display sleep mode. + + Args: + value (bool): if True enable sleep mode. if False disable sleep + mode + """ + if value: + self._write(ST7789_SLPIN) + else: + self._write(ST7789_SLPOUT) + + def inversion_mode(self, value): + """ + Enable or disable display inversion mode. + + Args: + value (bool): if True enable inversion mode. if False disable + inversion mode + """ + if value: + self._write(ST7789_INVON) + else: + self._write(ST7789_INVOFF) + + def _set_color_mode(self, mode): + """ + Set display color mode. + + Args: + mode (int): color mode + COLOR_MODE_65K, COLOR_MODE_262K, COLOR_MODE_12BIT, + COLOR_MODE_16BIT, COLOR_MODE_18BIT, COLOR_MODE_16M + """ + self._write(ST7789_COLMOD, bytes([mode & 0x77])) + + def rotation(self, rotation): + """ + Set display rotation. + + Args: + rotation (int): + - 0-Portrait + - 1-Landscape + - 2-Inverted Portrait + - 3-Inverted Landscape + """ + + rotation %= 4 + self._rotation = rotation + madctl = ROTATIONS[rotation] + + if self._display_width == 320: + table = WIDTH_320 + elif self._display_width == 240: + table = WIDTH_240 + elif self._display_width == 135: + table = WIDTH_135 + else: + raise ValueError( + "Unsupported display. 320x240, 240x240 and 135x240 are supported." + ) + + self.width, self.height, self.xstart, self.ystart = table[rotation] + self._write(ST7789_MADCTL, bytes([madctl])) + + def _set_columns(self, start, end): + """ + Send CASET (column address set) command to display. + + Args: + start (int): column start address + end (int): column end address + """ + if start <= end <= self.width: + self._write(ST7789_CASET, _encode_pos( + start+self.xstart, end + self.xstart)) + + def _set_rows(self, start, end): + """ + Send RASET (row address set) command to display. + + Args: + start (int): row start address + end (int): row end address + """ + if start <= end <= self.height: + self._write(ST7789_RASET, _encode_pos( + start+self.ystart, end+self.ystart)) + + def _set_window(self, x0, y0, x1, y1): + """ + Set window to column and row address. + + Args: + x0 (int): column start address + y0 (int): row start address + x1 (int): column end address + y1 (int): row end address + """ + self._set_columns(x0, x1) + self._set_rows(y0, y1) + self._write(ST7789_RAMWR) + + + def blit_buffer(self, buffer, x, y, width, height): + """ + Copy buffer to display at the given location. + + Args: + buffer (bytes): Data to copy to display + x (int): Top left corner x coordinate + Y (int): Top left corner y coordinate + width (int): Width + height (int): Height + """ + self._set_window(x, y, x + width - 1, y + height - 1) + self._write(None, buffer)