diff --git a/Metro/Metro_RP2350_Match3/autosave_resume_demo/code.py b/Metro/Metro_RP2350_Match3/autosave_resume_demo/code.py new file mode 100644 index 000000000..4f508dbac --- /dev/null +++ b/Metro/Metro_RP2350_Match3/autosave_resume_demo/code.py @@ -0,0 +1,251 @@ +# SPDX-FileCopyrightText: 2024 Tim Cocks for Adafruit Industries +# SPDX-License-Identifier: MIT +""" +This example demonstrates basic autosave and resume functionality. There are two buttons +that can be clicked to increment respective counters. The number of clicks is stored +in a game_state dictionary and saved to a data file on the SDCard. When the code first +launches it will read the data file and load the game_state from it. +""" +import array +from io import BytesIO +import os + +import board +import busio +import digitalio +import displayio +import msgpack +import storage +import supervisor +import terminalio +import usb + +import adafruit_sdcard +from adafruit_display_text.bitmap_label import Label +from adafruit_button import Button + +# use the default built-in display +display = supervisor.runtime.display + +# button configuration +BUTTON_WIDTH = 100 +BUTTON_HEIGHT = 30 +BUTTON_STYLE = Button.ROUNDRECT + +# game state object will get loaded from SDCard +# or a new one initialized as a dictionary +game_state = None + +save_to = None + +# boolean variables for possible SDCard states +sd_pins_in_use = False + +# The SD_CS pin is the chip select line. +SD_CS = board.SD_CS + +# try to Connect to the sdcard card and mount the filesystem. +try: + # initialze CS pin + cs = digitalio.DigitalInOut(SD_CS) +except ValueError: + # likely the SDCard was auto-initialized by the core + sd_pins_in_use = True + +try: + # if sd CS pin was not in use + if not sd_pins_in_use: + # try to initialize and mount the SDCard + sdcard = adafruit_sdcard.SDCard( + busio.SPI(board.SD_SCK, board.SD_MOSI, board.SD_MISO), cs + ) + vfs = storage.VfsFat(sdcard) + storage.mount(vfs, "/sd") + + # check for the autosave data file + if "autosave_demo.dat" in os.listdir("/sd/"): + # if the file is found read data from it into a BytesIO buffer + buffer = BytesIO() + with open("/sd/autosave_demo.dat", "rb") as f: + buffer.write(f.read()) + buffer.seek(0) + + # unpack the game_state object from the read data in the buffer + game_state = msgpack.unpack(buffer) + print(game_state) + + # if placeholder.txt file does not exist + if "placeholder.txt" not in os.listdir("/sd/"): + # if we made it to here then /sd/ exists and has a card + # so use it for save data + save_to = "/sd/autosave_demo.dat" +except OSError as e: + # sdcard init or mounting failed + raise OSError( + "This demo requires an SDCard. Please power off the device " + + "insert an SDCard and then plug it back in." + ) from e + +# if no saved game_state was loaded +if game_state is None: + # create a new game state dictionary + game_state = {"pink_count": 0, "blue_count": 0} + +# Make the display context +main_group = displayio.Group() +display.root_group = main_group + +# make buttons +blue_button = Button( + x=30, + y=40, + width=BUTTON_WIDTH, + height=BUTTON_HEIGHT, + style=BUTTON_STYLE, + fill_color=0x0000FF, + outline_color=0xFFFFFF, + label="BLUE", + label_font=terminalio.FONT, + label_color=0xFFFFFF, +) + +pink_button = Button( + x=30, + y=80, + width=BUTTON_WIDTH, + height=BUTTON_HEIGHT, + style=BUTTON_STYLE, + fill_color=0xFF00FF, + outline_color=0xFFFFFF, + label="PINK", + label_font=terminalio.FONT, + label_color=0x000000, +) + +# add them to a list for easy iteration +all_buttons = [blue_button, pink_button] + +# Add buttons to the display context +main_group.append(blue_button) +main_group.append(pink_button) + +# make labels for each button +blue_lbl = Label( + terminalio.FONT, text=f"Blue: {game_state['blue_count']}", color=0x3F3FFF +) +blue_lbl.anchor_point = (0, 0) +blue_lbl.anchored_position = (4, 4) +pink_lbl = Label( + terminalio.FONT, text=f"Pink: {game_state['pink_count']}", color=0xFF00FF +) +pink_lbl.anchor_point = (0, 0) +pink_lbl.anchored_position = (4, 4 + 14) +main_group.append(blue_lbl) +main_group.append(pink_lbl) + +# load the mouse cursor bitmap +mouse_bmp = displayio.OnDiskBitmap("mouse_cursor.bmp") + +# make the background pink pixels transparent +mouse_bmp.pixel_shader.make_transparent(0) + +# create a TileGrid for the mouse, using its bitmap and pixel_shader +mouse_tg = displayio.TileGrid(mouse_bmp, pixel_shader=mouse_bmp.pixel_shader) + +# move it to the center of the display +mouse_tg.x = display.width // 2 +mouse_tg.y = display.height // 2 + +# add the mouse tilegrid to main_group +main_group.append(mouse_tg) + +# scan for connected USB device and loop over any found +for device in usb.core.find(find_all=True): + # print device info + print(f"{device.idVendor:04x}:{device.idProduct:04x}") + print(device.manufacturer, device.product) + print(device.serial_number) + # assume the device is the mouse + mouse = device + +# detach the kernel driver if needed +if mouse.is_kernel_driver_active(0): + mouse.detach_kernel_driver(0) + +# set configuration on the mouse so we can use it +mouse.set_configuration() + +# buffer to hold mouse data +# Boot mice have 4 byte reports +buf = array.array("b", [0] * 4) + + +def save_game_state(): + """ + msgpack the game_state and save it to the autosave data file + :return: + """ + b = BytesIO() + msgpack.pack(game_state, b) + b.seek(0) + with open(save_to, "wb") as savefile: + savefile.write(b.read()) + + +# main loop +while True: + try: + # attempt to read data from the mouse + # 10ms timeout, so we don't block long if there + # is no data + count = mouse.read(0x81, buf, timeout=10) + except usb.core.USBTimeoutError: + # skip the rest of the loop if there is no data + continue + + # update the mouse tilegrid x and y coordinates + # based on the delta values read from the mouse + mouse_tg.x = max(0, min(display.width - 1, mouse_tg.x + buf[1])) + mouse_tg.y = max(0, min(display.height - 1, mouse_tg.y + buf[2])) + + # if left click is pressed + if buf[0] & (1 << 0) != 0: + # get the current cursor coordinates + coords = (mouse_tg.x, mouse_tg.y, 0) + + # loop over the buttons + for button in all_buttons: + # if the current button contains the mouse coords + if button.contains(coords): + # if the button isn't already in the selected state + if not button.selected: + # enter selected state + button.selected = True + + # if it is the pink button + if button == pink_button: + # increment pink count + game_state["pink_count"] += 1 + # update the label + pink_lbl.text = f"Pink: {game_state['pink_count']}" + + # if it is the blue button + elif button == blue_button: + # increment blue count + game_state["blue_count"] += 1 + # update the label + blue_lbl.text = f"Blue: {game_state['blue_count']}" + + # save the new game state + save_game_state() + + # if the click is not on the current button + else: + # set this button as not selected + button.selected = False + + # left click is not pressed + else: + # set all buttons as not selected + for button in all_buttons: + button.selected = False diff --git a/Metro/Metro_RP2350_Match3/autosave_resume_demo/mouse_cursor.bmp b/Metro/Metro_RP2350_Match3/autosave_resume_demo/mouse_cursor.bmp new file mode 100644 index 000000000..94ec32889 Binary files /dev/null and b/Metro/Metro_RP2350_Match3/autosave_resume_demo/mouse_cursor.bmp differ diff --git a/Metro/Metro_RP2350_Match3/match3_game/btn_exit.bmp b/Metro/Metro_RP2350_Match3/match3_game/btn_exit.bmp new file mode 100644 index 000000000..2c3347e6b Binary files /dev/null and b/Metro/Metro_RP2350_Match3/match3_game/btn_exit.bmp differ diff --git a/Metro/Metro_RP2350_Match3/match3_game/btn_no_set.bmp b/Metro/Metro_RP2350_Match3/match3_game/btn_no_set.bmp new file mode 100644 index 000000000..eb23afbda Binary files /dev/null and b/Metro/Metro_RP2350_Match3/match3_game/btn_no_set.bmp differ diff --git a/Metro/Metro_RP2350_Match3/match3_game/btn_play_again.bmp b/Metro/Metro_RP2350_Match3/match3_game/btn_play_again.bmp new file mode 100644 index 000000000..ce4b8fed9 Binary files /dev/null and b/Metro/Metro_RP2350_Match3/match3_game/btn_play_again.bmp differ diff --git a/Metro/Metro_RP2350_Match3/match3_game/code.py b/Metro/Metro_RP2350_Match3/match3_game/code.py new file mode 100644 index 000000000..3dd855aaf --- /dev/null +++ b/Metro/Metro_RP2350_Match3/match3_game/code.py @@ -0,0 +1,458 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025 Tim Cocks for Adafruit Industries +# +# SPDX-License-Identifier: MIT +""" +Match3 game inspired by the Set card game. Two players compete +to find sets of cards that share matching or mis-matching traits. +""" +import array +import atexit +import io +import os +import time + +import board +import busio +import digitalio +import supervisor +import terminalio +import usb +from tilepalettemapper import TilePaletteMapper +from displayio import TileGrid, Group, Palette, OnDiskBitmap, Bitmap +from adafruit_display_text.text_box import TextBox +import adafruit_usb_host_descriptors +from adafruit_debouncer import Debouncer +import adafruit_sdcard +import msgpack +import storage +from match3_game_helpers import ( + Match3Game, + STATE_GAMEOVER, + STATE_PLAYING_SETCALLED, + GameOverException, +) + +original_autoreload_val = supervisor.runtime.autoreload +supervisor.runtime.autoreload = False + +AUTOSAVE_FILENAME = "match3_game_autosave.dat" + +main_group = Group() +display = supervisor.runtime.display + +# set up scale factor of 2 for larger display resolution +scale_factor = 1 +if display.width > 360: + scale_factor = 2 + main_group.scale = scale_factor + +save_to = None +game_state = None +try: + # check for autosave file on CPSAVES drive + if AUTOSAVE_FILENAME in os.listdir("/saves/"): + savegame_buffer = io.BytesIO() + with open(f"/saves/{AUTOSAVE_FILENAME}", "rb") as f: + savegame_buffer.write(f.read()) + savegame_buffer.seek(0) + game_state = msgpack.unpack(savegame_buffer) + print(game_state) + + # if we made it to here then /saves/ exist so use it for + # save data + save_to = f"/saves/{AUTOSAVE_FILENAME}" +except OSError as e: + # no /saves/ dir likely means no CPSAVES + pass + +sd_pins_in_use = False + +if game_state is None: + # try to use sdcard for saves + # The SD_CS pin is the chip select line. + SD_CS = board.SD_CS + + # Connect to the card and mount the filesystem. + try: + cs = digitalio.DigitalInOut(SD_CS) + except ValueError: + sd_pins_in_use = True + + print(f"sd pins in use: {sd_pins_in_use}") + try: + if not sd_pins_in_use: + sdcard = adafruit_sdcard.SDCard( + busio.SPI(board.SD_SCK, board.SD_MOSI, board.SD_MISO), cs + ) + vfs = storage.VfsFat(sdcard) + storage.mount(vfs, "/sd") + + if "set_game_autosave.dat" in os.listdir("/sd/"): + savegame_buffer = io.BytesIO() + with open("/sd/set_game_autosave.dat", "rb") as f: + savegame_buffer.write(f.read()) + savegame_buffer.seek(0) + game_state = msgpack.unpack(savegame_buffer) + print(game_state) + + if "placeholder.txt" not in os.listdir("/sd/"): + # if we made it to here then /sd/ exists and has a card + # so use it for save data + save_to = "/sd/set_game_autosave.dat" + except OSError: + # no SDcard + pass + +# background color +bg_bmp = Bitmap( + display.width // scale_factor // 10, display.height // scale_factor // 10, 1 +) +bg_palette = Palette(1) +bg_palette[0] = 0x888888 +bg_tg = TileGrid(bg_bmp, pixel_shader=bg_palette) +bg_group = Group(scale=10) +bg_group.append(bg_tg) +main_group.append(bg_group) + +# create Game helper object +match3_game = Match3Game( + game_state=game_state, + display_size=(display.width // scale_factor, display.height // scale_factor), + save_location=save_to, +) +main_group.append(match3_game) + +# create a group to hold the game over elements +game_over_group = Group() + +# create a TextBox to hold the game over message +game_over_label = TextBox( + terminalio.FONT, + text="", + color=0xFFFFFF, + background_color=0x111111, + width=display.width // scale_factor // 2, + height=110, + align=TextBox.ALIGN_CENTER, +) +# move it to the center top of the display +game_over_label.anchor_point = (0, 0) +game_over_label.anchored_position = ( + display.width // scale_factor // 2 - (game_over_label.width) // 2, + 40, +) + +# make it hidden, we'll show it when the game is over. +game_over_group.hidden = True + +# add the game over lable to the game over group +game_over_group.append(game_over_label) + +# load the play again, and exit button bitmaps +play_again_btn_bmp = OnDiskBitmap("btn_play_again.bmp") +exit_btn_bmp = OnDiskBitmap("btn_exit.bmp") + +# create TileGrid for the play again button +play_again_btn = TileGrid( + bitmap=play_again_btn_bmp, pixel_shader=play_again_btn_bmp.pixel_shader +) + +# transparent pixels in the corners for the rounded corner effect +play_again_btn_bmp.pixel_shader.make_transparent(0) + +# centered within the display, offset to the left +play_again_btn.x = ( + display.width // scale_factor // 2 - (play_again_btn_bmp.width) // 2 - 30 +) + +# inside the bounds of the game over label, so it looks like a dialog visually +play_again_btn.y = 100 + +# create TileGrid for the exit button +exit_btn = TileGrid(bitmap=exit_btn_bmp, pixel_shader=exit_btn_bmp.pixel_shader) + +# transparent pixels in the corners for the rounded corner effect +exit_btn_bmp.pixel_shader.make_transparent(0) + +# centered within the display, offset to the right +exit_btn.x = display.width // scale_factor // 2 - (exit_btn_bmp.width) // 2 + 30 + +# inside the bounds of the game over label, so it looks like a dialog visually +exit_btn.y = 100 + +# add the play again and exit buttons to the game over group +game_over_group.append(play_again_btn) +game_over_group.append(exit_btn) +main_group.append(game_over_group) + +# wait a second for USB devices to be ready +time.sleep(1) + +# load the mouse bitmap +mouse_bmp = OnDiskBitmap("mouse_cursor.bmp") + +# make the background pink pixels transparent +mouse_bmp.pixel_shader.make_transparent(0) + +# list for mouse tilegrids +mouse_tgs = [] +# list for palette mappers, one for each mouse +palette_mappers = [] +# list for mouse colors +colors = [0x2244FF, 0xFFFF00] + +# remap palette will have the 3 colors from mouse bitmap +# and the two colors from the mouse colors list +remap_palette = Palette(3 + len(colors)) +# index 0 is transparent +remap_palette.make_transparent(0) + +# copy the 3 colors from the mouse bitmap palette +for i in range(3): + remap_palette[i] = mouse_bmp.pixel_shader[i] + +# copy the 2 colors from the mouse colors list +for i in range(2): + remap_palette[i + 3] = colors[i] + +# create tile palette mappers +for i in range(2): + palette_mapper = TilePaletteMapper(remap_palette, 3, 1, 1) + # remap index 2 to each of the colors in mouse colors list + palette_mapper[0] = [0, 1, i + 3] + palette_mappers.append(palette_mapper) + + # create tilegrid for each mouse + mouse_tg = TileGrid(mouse_bmp, pixel_shader=palette_mapper) + mouse_tg.x = display.width // scale_factor // 2 - (i * 12) + mouse_tg.y = display.height // scale_factor // 2 + mouse_tgs.append(mouse_tg) + +# USB info lists +mouse_interface_indexes = [] +mouse_endpoint_addresses = [] +kernel_driver_active_flags = [] +# USB device object instance list +mice = [] +# buffers list for mouse packet data +mouse_bufs = [] +# debouncers list for debouncing mouse left clicks +mouse_debouncers = [] + +# scan for connected USB devices +for device in usb.core.find(find_all=True): + # check if current device is has a boot mouse endpoint + mouse_interface_index, mouse_endpoint_address = ( + adafruit_usb_host_descriptors.find_boot_mouse_endpoint(device) + ) + if mouse_interface_index is not None and mouse_endpoint_address is not None: + # if it does have a boot mouse endpoint then add information to the + # usb info lists + mouse_interface_indexes.append(mouse_interface_index) + mouse_endpoint_addresses.append(mouse_endpoint_address) + + # add the mouse device instance to list + mice.append(device) + print( + f"mouse interface: {mouse_interface_index} " + + f"endpoint_address: {hex(mouse_endpoint_address)}" + ) + + # detach kernel driver if needed + kernel_driver_active_flags.append(device.is_kernel_driver_active(0)) + if device.is_kernel_driver_active(0): + device.detach_kernel_driver(0) + + # set the mouse configuration so it can be used + device.set_configuration() + + +def is_mouse1_left_clicked(): + """ + Check if mouse 1 left click is pressed + :return: True if mouse 1 left click is pressed + """ + return is_left_mouse_clicked(mouse_bufs[0]) + + +def is_mouse2_left_clicked(): + """ + Check if mouse 2 left click is pressed + :return: True if mouse 2 left click is pressed + """ + return is_left_mouse_clicked(mouse_bufs[1]) + + +def is_left_mouse_clicked(buf): + """ + Check if a mouse is pressed given its packet buffer + filled with read data + :param buf: the buffer containing the packet data + :return: True if mouse left click is pressed + """ + val = buf[0] & (1 << 0) != 0 + return val + + +def is_right_mouse_clicked(buf): + """ + check if a mouse right click is pressed given its packet buffer + :param buf: the buffer containing the packet data + :return: True if mouse right click is pressed + """ + val = buf[0] & (1 << 1) != 0 + return val + + +# print(f"addresses: {mouse_endpoint_addresses}") +# print(f"indexes: {mouse_interface_indexes}") + +for mouse_tg in mouse_tgs: + # add the mouse to the main group + main_group.append(mouse_tg) + + # Buffer to hold data read from the mouse + # Boot mice have 4 byte reports + mouse_bufs.append(array.array("b", [0] * 8)) + +# create debouncer objects for left click functions +mouse_debouncers.append(Debouncer(is_mouse1_left_clicked)) +mouse_debouncers.append(Debouncer(is_mouse2_left_clicked)) + +# set main_group as root_group, so it is visible on the display +display.root_group = main_group + +# variable to hold winning player +winner = None + + +def get_mouse_deltas(buffer, read_count): + """ + Given a mouse packet buffer and a read count of number of bytes read, + return the delta x and y values of the mouse. + :param buffer: the buffer containing the packet data + :param read_count: the number of bytes read from the mouse + :return: tuple containing x and y delta values + """ + if read_count == 4: + delta_x = buffer[1] + delta_y = buffer[2] + elif read_count == 8: + delta_x = buffer[2] + delta_y = buffer[4] + else: + raise ValueError(f"Unsupported mouse packet size: {read_count}, must be 4 or 8") + return delta_x, delta_y + + +def atexit_callback(): + """ + re-attach USB devices to kernel if needed, and set + autoreload back to the original state. + :return: + """ + for _i, _mouse in enumerate(mice): + if kernel_driver_active_flags[_i]: + if not _mouse.is_kernel_driver_active(0): + _mouse.attach_kernel_driver(0) + supervisor.runtime.autoreload = original_autoreload_val + + +atexit.register(atexit_callback) + +# main loop +while True: + + # if set has been called + if match3_game.cur_state == STATE_PLAYING_SETCALLED: + # update the progress bar ticking down + match3_game.update_active_turn_progress() + + # loop over the mice objects + for i, mouse in enumerate(mice): + mouse_tg = mouse_tgs[i] + # attempt mouse read + try: + # read data from the mouse, small timeout so we move on + # quickly if there is no data + data_len = mouse.read( + mouse_endpoint_addresses[i], mouse_bufs[i], timeout=10 + ) + mouse_deltas = get_mouse_deltas(mouse_bufs[i], data_len) + # if we got data, then update the mouse cursor on the display + # using min and max to keep it within the bounds of the display + mouse_tg.x = max( + 0, + min( + display.width // scale_factor - 1, mouse_tg.x + mouse_deltas[0] // 2 + ), + ) + mouse_tg.y = max( + 0, + min( + display.height // scale_factor - 1, + mouse_tg.y + mouse_deltas[1] // 2, + ), + ) + + # timeout error is raised if no data was read within the allotted timeout + except usb.core.USBTimeoutError: + pass + + # update the mouse debouncers + mouse_debouncers[i].update() + + try: + # if the current mouse is right-clicking + if is_right_mouse_clicked(mouse_bufs[i]): + # let the game object handle the right-click + match3_game.handle_right_click(i) + + # if the current mouse left-clicked + if mouse_debouncers[i].rose: + # get the current mouse coordinates + coords = (mouse_tg.x, mouse_tg.y, 0) + + # if the current state is GAMEOVER + if match3_game.cur_state != STATE_GAMEOVER: + # let the game object handle the click event + match3_game.handle_left_click(i, coords) + else: + # if the mouse point is within the play again + # button bounding box + if play_again_btn.contains(coords): + # set next code file to this one + supervisor.set_next_code_file(__file__) + # reload + supervisor.reload() + + # if the mouse point is within the exit + # button bounding box + if exit_btn.contains(coords): + supervisor.reload() + + # if the game is over + except GameOverException: + # check for a winner + winner = None + if match3_game.scores[0] > match3_game.scores[1]: + winner = 0 + elif match3_game.scores[0] < match3_game.scores[1]: + winner = 1 + + # if there was a winner + if winner is not None: + # show a message with the winning player + message = f"\nGame Over\nPlayer{winner + 1} Wins!" + game_over_label.color = colors[winner] + game_over_label.text = message + + else: # there wasn't a winner + # show a tie game message + message = "\nGame Over\nTie Game Everyone Wins!" + + # make the gameover group visible + game_over_group.hidden = False + + # delete the autosave file. + os.remove(save_to) diff --git a/Metro/Metro_RP2350_Match3/match3_game/match3_cards_spritesheet.bmp b/Metro/Metro_RP2350_Match3/match3_game/match3_cards_spritesheet.bmp new file mode 100644 index 000000000..78e34c0af Binary files /dev/null and b/Metro/Metro_RP2350_Match3/match3_game/match3_cards_spritesheet.bmp differ diff --git a/Metro/Metro_RP2350_Match3/match3_game/match3_game_helpers.py b/Metro/Metro_RP2350_Match3/match3_game/match3_game_helpers.py new file mode 100644 index 000000000..fe9cbd262 --- /dev/null +++ b/Metro/Metro_RP2350_Match3/match3_game/match3_game_helpers.py @@ -0,0 +1,779 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025 Tim Cocks for Adafruit Industries +# +# SPDX-License-Identifier: MIT +import os +import random +import time +from io import BytesIO + +import terminalio + +import adafruit_imageload +from displayio import Group, TileGrid, OnDiskBitmap, Palette, Bitmap +import bitmaptools +import msgpack +from tilepalettemapper import TilePaletteMapper +import ulab.numpy as np + +from adafruit_display_text.bitmap_label import Label +from adafruit_displayio_layout.layouts.grid_layout import GridLayout +from adafruit_button import Button +from adafruit_progressbar.horizontalprogressbar import ( + HorizontalProgressBar, + HorizontalFillDirection, +) + +# pylint: disable=too-many-locals, too-many-nested-blocks, too-many-branches, too-many-statements +colors = [0x2244FF, 0xFFFF00] +STATE_TITLE = 0 +STATE_PLAYING_OPEN = 1 +STATE_PLAYING_SETCALLED = 2 +STATE_GAMEOVER = 3 + +ACTIVE_TURN_TIME_LIMIT = 10.0 + + +def random_selection(lst, count): + """ + Select items randomly from a list of items. + + returns a list of length count containing the selected items. + """ + if len(lst) < count: + raise ValueError("Count must be less than or equal to length of list") + selection = [] + while len(selection) < count: + selection.append(lst.pop(random.randrange(len(lst)))) + return selection + + +def validate_set(card_1, card_2, card_3): + """ + Check if a set of 3 cards is valid + :param card_1: the first card + :param card_2: the second card + :param card_3: the third card + :return: True if they are a valid set, False otherwise + """ + matrix_sums = card_1.tuple + card_2.tuple + card_3.tuple + for val in matrix_sums: + if val % 3 != 0: + return False + return True + + +class Match3Card(Group): + """ + Class representing a Match3 Card. Keeps track of shape, count, color, and fill. + + tuple value mappings: + + color, shape, fill, count + 0 , 1 , 2 , 1 + + colors + purple: 0 + red: 1 + green: 2 + + shapes + rectangle: 0 + triangle: 1 + circle: 2 + + fill + outline: 0 + filled: 1 + striped: 2 + + count + one: 0 + two: 1 + three: 2 + """ + + TUPLE_VALUE_TO_TILE_INDEX_LUT = { + # rectangle filled + (0, 1, 0): 0, + (0, 1, 1): 1, + (0, 1, 2): 2, + # triangle filled + (1, 1, 0): 3, + (1, 1, 1): 4, + (1, 1, 2): 5, + # circle filled + (2, 1, 0): 6, + (2, 1, 1): 13, + (2, 1, 2): 20, + # rectangle outline + (0, 0, 0): 7, + (0, 0, 1): 8, + (0, 0, 2): 9, + # triangle outline + (1, 0, 0): 10, + (1, 0, 1): 11, + (1, 0, 2): 12, + # circle outline + (2, 0, 0): 21, + (2, 0, 1): 22, + (2, 0, 2): 23, + # rectangle striped + (0, 2, 0): 14, + (0, 2, 1): 15, + (0, 2, 2): 16, + # triangle striped + (1, 2, 0): 17, + (1, 2, 1): 18, + (1, 2, 2): 19, + # circle striped + (2, 2, 0): 24, + (2, 2, 1): 25, + (2, 2, 2): 26, + } + + def __init__(self, card_tuple, **kwargs): + # tile palette mapper to color the card + self._mapper = TilePaletteMapper(kwargs["pixel_shader"], 5, 1, 1) + kwargs["pixel_shader"] = self._mapper + # tile grid to for the visible sprite + self._tilegrid = TileGrid(**kwargs) + self._tilegrid.x = 4 + self._tilegrid.y = 4 + # initialize super class Group + super().__init__() + # add tilegrid to self instance Group + self.append(self._tilegrid) + # numpy array of the card tuple values + self._tuple = np.array(list(card_tuple), dtype=np.uint8) + # set the sprite and color based on card attributes + self._update_card_attributes() + + def _update_card_attributes(self): + """ + set the sprite and color based on card attributes + :return: None + """ + # set color + color_tuple_val = self._tuple[0] + self._mapper[0] = [0, color_tuple_val + 2, 2, 3, 4] + + # set tile + self._tilegrid[0] = Match3Card.TUPLE_VALUE_TO_TILE_INDEX_LUT[ + (self._tuple[1], self._tuple[2], self._tuple[3]) + ] + + def __str__(self): + return self.tuple + + def __repr__(self): + return self.tuple + + @property + def tuple(self): + """ + The tuple containing attributes values for this card. + """ + return self._tuple + + def contains(self, coordinates): + """ + Check if the cards bounding box contains the given coordinates. + :param coordinates: the coordinates to check + :return: True if the bounding box contains the given coordinates, False otherwise + """ + return ( + self.x <= coordinates[0] <= self.x + self._tilegrid.tile_width + and self.y <= coordinates[1] <= self.y + self._tilegrid.tile_height + ) + + +class Match3Game(Group): + """ + Match3 Game helper class + + Holds visual elements, manages state machine. + """ + + def __init__(self, game_state=None, display_size=None, save_location=None): + # initialize super Group instance + super().__init__() + self.game_state = game_state + self.display_size = display_size + + # list of Match3Card instances representing the current deck + self.play_deck = [] + + # load the spritesheet + self.card_spritesheet, self.card_palette = adafruit_imageload.load( + "match3_cards_spritesheet.bmp" + ) + + # start in the TITLE state + self.cur_state = STATE_TITLE + + # create a grid layout to help place cards neatly + # into a grid on the display + grid_size = (6, 3) + self.card_grid = GridLayout( + x=10, y=10, width=260, height=200, grid_size=grid_size + ) + + # no set button in the bottom right + self.no_set_btn_bmp = OnDiskBitmap("btn_no_set.bmp") + self.no_set_btn_bmp.pixel_shader.make_transparent(0) + self.no_set_btn = TileGrid( + bitmap=self.no_set_btn_bmp, pixel_shader=self.no_set_btn_bmp.pixel_shader + ) + self.no_set_btn.x = display_size[0] - self.no_set_btn.tile_width + self.no_set_btn.y = display_size[1] - self.no_set_btn.tile_height + self.append(self.no_set_btn) + + # list to hold score labels, one for each player + self.score_lbls = [] + + # player scores start at 0 + self.scores = [0, 0] + + self.save_location = save_location + + # initialize and position the score labels + for i in range(2): + score_lbl = Label(terminalio.FONT, text=f"P{i + 1}: 0", color=colors[i]) + self.score_lbls.append(score_lbl) + score_lbl.anchor_point = (1.0, 0.0) + score_lbl.anchored_position = (display_size[0] - 2, 2 + i * 12) + self.append(score_lbl) + + # deck count label in the bottom left + self.deck_count_lbl = Label( + terminalio.FONT, text=f"Deck: {len(self.play_deck)}" + ) + self.deck_count_lbl.anchor_point = (0.0, 1.0) + self.deck_count_lbl.anchored_position = (2, display_size[1] - 2) + self.append(self.deck_count_lbl) + + # will hold active player index + self.active_player = None + + # list of player index who have called no set + self.no_set_called_player_indexes = [] + + # active turn countdown progress bar + # below the score labels + self.active_turn_countdown = HorizontalProgressBar( + (display_size[0] - 64, 30), + (60, 6), + direction=HorizontalFillDirection.LEFT_TO_RIGHT, + min_value=0, + max_value=ACTIVE_TURN_TIME_LIMIT * 10, + ) + self.active_turn_countdown.hidden = True + self.append(self.active_turn_countdown) + + # will hold the timestamp when active turn began + self.active_turn_start_time = None + + # add the card grid to self instance Group + self.append(self.card_grid) + + # list of card objects that have been clicked + self.clicked_cards = [] + + # list of coordinates that have been clicked + self.clicked_coordinates = [] + + # initialize title screen + self.title_screen = Match3TitleScreen(display_size) + self.append(self.title_screen) + + # set up the clicked card indicator borders + self.clicked_card_indicator_palette = Palette(2) + self.clicked_card_indicator_palette[0] = 0x000000 + self.clicked_card_indicator_palette.make_transparent(0) + self.clicked_card_indicator_palette[1] = colors[0] + self.clicked_card_indicator_bmp = Bitmap(24 + 8, 32 + 8, 2) + self.clicked_card_indicator_bmp.fill(1) + bitmaptools.fill_region( + self.clicked_card_indicator_bmp, + 2, + 2, + self.clicked_card_indicator_bmp.width - 2, + self.clicked_card_indicator_bmp.height - 2, + value=0, + ) + self.clicked_card_indicators = [] + for _ in range(3): + self.clicked_card_indicators.append( + TileGrid( + bitmap=self.clicked_card_indicator_bmp, + pixel_shader=self.clicked_card_indicator_palette, + ) + ) + + def update_scores(self): + """ + Update the score labels to reflect the current player scores + :return: None + """ + for player_index in range(2): + prefix = "" + if player_index == self.active_player: + prefix = ">" + if player_index in self.no_set_called_player_indexes: + prefix = "*" + self.score_lbls[player_index].text = ( + f"{prefix}P{player_index + 1}: {self.scores[player_index]}" + ) + + def save_game_state(self): + """ + Save the game state to a file + :return: None + """ + # if there is a valid save location + if self.save_location is not None: + # create a dictionary object to store the game state + game_state = {"scores": self.scores, "board": {}, "deck": []} + # read the current board state into the dictionary object + for _y in range(3): + for _x in range(6): + try: + content = self.card_grid.get_content((_x, _y)) + game_state["board"][f"{_x},{_y}"] = tuple(content.tuple) + except KeyError: + pass + # read the current deck state into the dictionary object + for card in self.play_deck: + game_state["deck"].append(tuple(card.tuple)) + + # msgpack the object and write it to a file + b = BytesIO() + msgpack.pack(game_state, b) + b.seek(0) + with open(self.save_location, "wb") as f: + f.write(b.read()) + + def load_from_game_state(self, game_state): + """ + Load game state from a dictionary. + :param game_state: The dictionary of game state to load + :return: None + """ + # loop over cards in the deck + for card_tuple in game_state["deck"]: + # create a card instance and add it to the deck + self.play_deck.append( + Match3Card( + card_tuple, + bitmap=self.card_spritesheet, + pixel_shader=self.card_palette, + tile_width=24, + tile_height=32, + ) + ) + + # loop over grid cells + for y in range(3): + for x in range(6): + # if the current cell is in the board state of the save game + if f"{x},{y}" in game_state["board"]: + # create a card instance and put it in the grid here + card_tuple = game_state["board"][f"{x},{y}"] + self.card_grid.add_content( + Match3Card( + card_tuple, + bitmap=self.card_spritesheet, + pixel_shader=self.card_palette, + tile_width=24, + tile_height=32, + ), + (x, y), + (1, 1), + ) + # set the scores from the game state + self.scores = game_state["scores"] + # update the visible score labels + self.update_scores() + # update the deck count label + self.deck_count_lbl.text = f"Deck: {len(self.play_deck)}" + + def init_new_game(self): + """ + Initialize a new game state. + :return: None + """ + self.play_deck = [] + # loop over the 3 possibilities in each of the 4 attributes + for _color in range(0, 3): + for _shape in range(0, 3): + for _fill in range(0, 3): + for _count in range(0, 3): + # create a card instance with the current attributes + self.play_deck.append( + Match3Card( + (_color, _shape, _fill, _count), + bitmap=self.card_spritesheet, + pixel_shader=self.card_palette, + tile_width=24, + tile_height=32, + ) + ) + + # draw the starting cards at random + starting_pool = random_selection(self.play_deck, 12) + + # put the starting cards into the grid layout + for y in range(3): + for x in range(4): + self.card_grid.add_content(starting_pool[y * 4 + x], (x, y), (1, 1)) + + # update the deck count label + self.deck_count_lbl.text = f"Deck: {len(self.play_deck)}" + + def handle_right_click(self, player_index): + """ + Handle right click event + :param player_index: the index of the player who clicked + :return: None + """ + # if the current state is open play + if self.cur_state == STATE_PLAYING_OPEN: + # if there is no active player + if self.active_player is None: + # if the player who right clicked is in the no set called list + if player_index in self.no_set_called_player_indexes: + # remove them from the no set called list + self.no_set_called_player_indexes.remove(player_index) + # set the active player to the player that clicked + self.active_player = player_index + # set the clicked card indicators to the active player's color + self.clicked_card_indicator_palette[1] = colors[player_index] + # set the current state to the set called state + self.cur_state = STATE_PLAYING_SETCALLED + # store timestamp of when the active turn began + self.active_turn_start_time = time.monotonic() + # make the countdown progress bar visible + self.active_turn_countdown.hidden = False + # set the value to the maximum of the progress bar + self.active_turn_countdown.value = 60 + # update the score labels to show the active player indicator + self.update_scores() + + def handle_left_click(self, player_index, coords): + """ + Handle left click events + :param player_index: the index of the player who clicked + :param coords: the coordinates where the mouse clicked + :return: None + """ + # if the current state is open playing + if self.cur_state == STATE_PLAYING_OPEN: + # if the click is on the no set button + if self.no_set_btn.contains(coords): + # if the player that clicked is not in the net set called list + if player_index not in self.no_set_called_player_indexes: + # add them to the no set called list + self.no_set_called_player_indexes.append(player_index) + + # if both players have called no set + if len(self.no_set_called_player_indexes) == 2: + # if there are no cards left in the deck + if len(self.play_deck) == 0: + # set the state to game over + self.cur_state = STATE_GAMEOVER + raise GameOverException() + + # empty the no set called list + self.no_set_called_player_indexes = [] + + # find the empty cells in the card grid + empty_cells = self.find_empty_cells() + # if there are more than 3 empty cells + if len(empty_cells) >= 3: + # draw 3 new cards + _new_cards = random_selection(self.play_deck, 3) + # place them in 3 of the empty cells + for i, _new_card in enumerate(_new_cards): + self.card_grid.add_content( + _new_card, empty_cells[i], (1, 1) + ) + + else: # there are no 3 empty cells + # redraw the original grid with 12 new cards + + # remove existing cards from the grid and + # return them to the deck. + for _y in range(3): + for _x in range(6): + try: + _remove_card = self.card_grid.pop_content( + (_x, _y) + ) + print(f"remove_card: {_remove_card}") + self.play_deck.append(_remove_card) + + except KeyError: + continue + + # draw 12 new cards from the deck + starting_pool = random_selection(self.play_deck, 12) + # place them into the grid + for y in range(3): + for x in range(4): + self.card_grid.add_content( + starting_pool[y * 4 + x], (x + 1, y), (1, 1) + ) + + # update the deck count label + self.deck_count_lbl.text = f"Deck: {len(self.play_deck)}" + # save the game state + self.save_game_state() + + # update the score labels to show the no set called indicator(s) + self.update_scores() + + # if the current state is set called + elif self.cur_state == STATE_PLAYING_SETCALLED: + # if the player that clicked is the active player + if player_index == self.active_player: + # get the coordinates that were clicked adjusting for the card_grid position + adjusted_coords = ( + coords[0] - self.card_grid.x, + coords[1] - self.card_grid.y, + 0, + ) + # check which cell contains the clicked coordinates + clicked_grid_cell_coordinates = self.card_grid.which_cell_contains( + coords + ) + # print(clicked_grid_cell_coordinates) + + # if a cell in the grid was clicked + if clicked_grid_cell_coordinates is not None: + # try to get the content of the clicked cell, a Card instance potentially + try: + clicked_cell_content = self.card_grid.get_content( + clicked_grid_cell_coordinates + ) + except KeyError: + # if no content is in the cell just return + return + + # check if the Card instance was clicked, and if the card + # isn't already in the list of clicked cards + if ( + clicked_cell_content.contains(adjusted_coords) + and clicked_cell_content not in self.clicked_cards + ): + + clicked_card = clicked_cell_content + # show the clicked card indicator in this cell + clicked_cell_content.insert( + 0, self.clicked_card_indicators[len(self.clicked_cards)] + ) + # add the card instance to the clicked cards list + self.clicked_cards.append(clicked_card) + + # add the coordinates to the clicked coordinates list + self.clicked_coordinates.append(clicked_grid_cell_coordinates) + + # if 3 cards have been clicked + if len(self.clicked_cards) == 3: + # check if the 3 cards make a valid set + valid_set = validate_set(self.clicked_cards[0], + self.clicked_cards[1], + self.clicked_cards[2]) + + # if they are a valid set + if valid_set: + # award a point to the active player + self.scores[self.active_player] += 1 + + # loop over the clicked coordinates + for coord in self.clicked_coordinates: + # remove the old card from this cell + _remove_card = self.card_grid.pop_content(coord) + # remove border from Match3Card group + _remove_card.pop(0) + + # find empty cells in the grid + empty_cells = self.find_empty_cells() + + # if there are at least 3 cards in the deck and + # at least 6 empty cells in the grid + if len(self.play_deck) >= 3 and len(empty_cells) > 6: + # deal 3 new cards to empty spots in the grid + for i in range(3): + _new_card = random_selection(self.play_deck, 1)[ + 0 + ] + self.card_grid.add_content( + _new_card, empty_cells[i], (1, 1) + ) + # update the deck count label + self.deck_count_lbl.text = ( + f"Deck: {len(self.play_deck)}" + ) + + # there are not at least 3 cards in the deck + # and at least 6 empty cells + else: + # if there are no empty cells + if len(self.find_empty_cells()) == 0: + # set the current state to game over + self.cur_state = STATE_GAMEOVER + raise GameOverException() + + else: # the 3 clicked cards are not a valid set + + # remove the clicked card indicators + for _ in range(3): + coords = self.clicked_coordinates.pop() + self.card_grid.get_content(coords).pop(0) + + # subtract a point from the active player + self.scores[self.active_player] -= 1 + + # save the game state + self.save_game_state() + # reset the clicked cards and coordinates lists + self.clicked_cards = [] + self.clicked_coordinates = [] + + # set the current state to open play + self.cur_state = STATE_PLAYING_OPEN + # set active player and active turn vars + self.active_player = None + self.active_turn_start_time = None + self.active_turn_countdown.hidden = True + # update the score labels + self.update_scores() + + # if the current state is title state + elif self.cur_state == STATE_TITLE: + # if the resume button is visible and was clicked + if ( + not self.title_screen.resume_btn.hidden + and self.title_screen.resume_btn.contains(coords) + ): + + # load the game from the given game state + self.load_from_game_state(self.game_state) + # hide the title screen + self.title_screen.hidden = True # pylint: disable=attribute-defined-outside-init + # set the current state to open play + self.cur_state = STATE_PLAYING_OPEN + + # if the new game button was clicked + elif self.title_screen.new_game_btn.contains(coords): + self.game_state = None + # delete the autosave file + try: + os.remove(self.save_location) + print("removed old game save file") + except OSError: + pass + # initialize a new game + self.init_new_game() + # hide the title screen + self.title_screen.hidden = True # pylint: disable=attribute-defined-outside-init + # set the current state to open play + self.cur_state = STATE_PLAYING_OPEN + + def find_empty_cells(self): + """ + find the cells within the card grid that are empty + :return: list of empty cell coordinate tuples. + """ + empty_cells = [] + for x in range(6): + for y in range(3): + try: + _content = self.card_grid.get_content((x, y)) + except KeyError: + empty_cells.append((x, y)) + return empty_cells + + def update_active_turn_progress(self): + """ + update the active turn progress bar countdown + :return: + """ + if self.cur_state == STATE_PLAYING_SETCALLED: + time_diff = time.monotonic() - self.active_turn_start_time + if time_diff > ACTIVE_TURN_TIME_LIMIT: + self.scores[self.active_player] -= 1 + self.active_player = None + self.update_scores() + self.cur_state = STATE_PLAYING_OPEN + self.active_turn_countdown.hidden = True + else: + self.active_turn_countdown.value = int( + (ACTIVE_TURN_TIME_LIMIT - time_diff) * 10 + ) + + +class GameOverException(Exception): + """ + Exception that indicates the game is over. + Message will contain the reason. + """ + + +class Match3TitleScreen(Group): + """ + Title screen for the Match3 game. + """ + + def __init__(self, display_size): + super().__init__() + self.display_size = display_size + # background bitmap color + bg_bmp = Bitmap(display_size[0] // 10, display_size[1] // 10, 1) + bg_palette = Palette(1) + bg_palette[0] = 0xFFFFFF + bg_tg = TileGrid(bg_bmp, pixel_shader=bg_palette) + bg_group = Group(scale=10) + bg_group.append(bg_tg) + self.append(bg_group) + + # load title card bitmap + title_card_bmp = OnDiskBitmap("title_card_match3.bmp") + title_card_tg = TileGrid( + title_card_bmp, pixel_shader=title_card_bmp.pixel_shader + ) + title_card_tg.x = 2 + if display_size[1] > 200: + title_card_tg.y = 20 + self.append(title_card_tg) + + # make resume and new game buttons + BUTTON_X = display_size[0] - 90 + BUTTON_WIDTH = 70 + BUTTON_HEIGHT = 20 + self.resume_btn = Button( + x=BUTTON_X, + y=40, + width=BUTTON_WIDTH, + height=BUTTON_HEIGHT, + style=Button.ROUNDRECT, + fill_color=0x6D2EDC, + outline_color=0x888888, + label="Resume", + label_font=terminalio.FONT, + label_color=0xFFFFFF, + ) + self.append(self.resume_btn) + self.new_game_btn = Button( + x=BUTTON_X, + y=40 + BUTTON_HEIGHT + 10, + width=BUTTON_WIDTH, + height=BUTTON_HEIGHT, + style=Button.RECT, + fill_color=0x0C9F0C, + outline_color=0x111111, + label="New Game", + label_font=terminalio.FONT, + label_color=0xFFFFFF, + ) + self.append(self.new_game_btn) diff --git a/Metro/Metro_RP2350_Match3/match3_game/mouse_cursor.bmp b/Metro/Metro_RP2350_Match3/match3_game/mouse_cursor.bmp new file mode 100644 index 000000000..94ec32889 Binary files /dev/null and b/Metro/Metro_RP2350_Match3/match3_game/mouse_cursor.bmp differ diff --git a/Metro/Metro_RP2350_Match3/match3_game/title_card_match3.bmp b/Metro/Metro_RP2350_Match3/match3_game/title_card_match3.bmp new file mode 100644 index 000000000..66f040fb0 Binary files /dev/null and b/Metro/Metro_RP2350_Match3/match3_game/title_card_match3.bmp differ diff --git a/Metro/Metro_RP2350_Match3/tilepalettemapper_demo/code.py b/Metro/Metro_RP2350_Match3/tilepalettemapper_demo/code.py new file mode 100644 index 000000000..45c82cc39 --- /dev/null +++ b/Metro/Metro_RP2350_Match3/tilepalettemapper_demo/code.py @@ -0,0 +1,50 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025 Tim Cocks for Adafruit Industries +# +# SPDX-License-Identifier: MIT + +import supervisor +from displayio import Group, OnDiskBitmap, TileGrid +from tilepalettemapper import TilePaletteMapper + +# use the default built-in display, +# the HSTX / PicoDVI display for the Metro RP2350 +display = supervisor.runtime.display + +# a group to hold all other visual elements +main_group = Group(scale=4, x=30, y=30) + +# set the main group to show on the display +display.root_group = main_group + +# load the sprite sheet bitmap +spritesheet_bmp = OnDiskBitmap("match3_cards_spritesheet.bmp") + +# create a TilePaletteMapper +tile_palette_mapper = TilePaletteMapper( + spritesheet_bmp.pixel_shader, # input pixel_shader + 5, # input color count + 3, # grid width + 1 # grid height +) + +# create a TileGrid to show some cards +cards_tilegrid = TileGrid(spritesheet_bmp, pixel_shader=tile_palette_mapper, + width=3, height=1, tile_width=24, tile_height=32) + +# set each tile in the grid to a different sprite index +cards_tilegrid[0, 0] = 10 +cards_tilegrid[1, 0] = 25 +cards_tilegrid[2, 0] = 2 + +# re-map each tile in the grid to use a different color for index 1 +# all other indexes remain their default values +tile_palette_mapper[0, 0] = [0, 2, 2, 3, 4] +tile_palette_mapper[1, 0] = [0, 3, 2, 3, 4] +tile_palette_mapper[2, 0] = [0, 4, 2, 3, 4] + +# add the tilegrid to the main group +main_group.append(cards_tilegrid) + +# wait forever so it remains visible on the display +while True: + pass diff --git a/Metro/Metro_RP2350_Match3/tilepalettemapper_demo/match3_cards_spritesheet.bmp b/Metro/Metro_RP2350_Match3/tilepalettemapper_demo/match3_cards_spritesheet.bmp new file mode 100644 index 000000000..78e34c0af Binary files /dev/null and b/Metro/Metro_RP2350_Match3/tilepalettemapper_demo/match3_cards_spritesheet.bmp differ diff --git a/Metro/Metro_RP2350_Match3/two_mice_demo/code.py b/Metro/Metro_RP2350_Match3/two_mice_demo/code.py new file mode 100644 index 000000000..93f51a511 --- /dev/null +++ b/Metro/Metro_RP2350_Match3/two_mice_demo/code.py @@ -0,0 +1,184 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025 Tim Cocks for Adafruit Industries +# +# SPDX-License-Identifier: MIT +import array +import supervisor +import terminalio +import usb.core +from adafruit_display_text.bitmap_label import Label +from displayio import Group, OnDiskBitmap, TileGrid, Palette + +import adafruit_usb_host_descriptors + +# use the default built-in display, +# the HSTX / PicoDVI display for the Metro RP2350 +display = supervisor.runtime.display + +# a group to hold all other visual elements +main_group = Group() + +# set the main group to show on the display +display.root_group = main_group + +# load the cursor bitmap file +mouse_bmp = OnDiskBitmap("mouse_cursor.bmp") + +# lists for labels, mouse tilegrids, and palettes. +# each mouse will get 1 of each item. All lists +# will end up with length 2. +output_lbls = [] +mouse_tgs = [] +palettes = [] + +# the different colors to use for each mouse cursor +# and labels +colors = [0xFF00FF, 0x00FF00] + +for i in range(2): + # create a palette for this mouse + mouse_palette = Palette(3) + # index zero is used for transparency + mouse_palette.make_transparent(0) + # add the palette to the list of palettes + palettes.append(mouse_palette) + + # copy the first two colors from mouse palette + for palette_color_index in range(2): + mouse_palette[palette_color_index] = mouse_bmp.pixel_shader[palette_color_index] + + # replace the last color with different color for each mouse + mouse_palette[2] = colors[i] + + # create a TileGrid for this mouse cursor. + # use the palette created above + mouse_tg = TileGrid(mouse_bmp, pixel_shader=mouse_palette) + + # move the mouse tilegrid to near the center of the display + mouse_tg.x = display.width // 2 - (i * 12) + mouse_tg.y = display.height // 2 + + # add this mouse tilegrid to the list of mouse tilegrids + mouse_tgs.append(mouse_tg) + + # add this mouse tilegrid to the main group so it will show + # on the display + main_group.append(mouse_tg) + + # create a label for this mouse + output_lbl = Label(terminalio.FONT, text=f"{mouse_tg.x},{mouse_tg.y}", color=colors[i], scale=1) + # anchored to the top left corner of the label + output_lbl.anchor_point = (0, 0) + + # move to op left corner of the display, moving + # down by a static amount to static the two labels + # one below the other + output_lbl.anchored_position = (1, 1 + i * 13) + + # add the label to the list of labels + output_lbls.append(output_lbl) + + # add the label to the main group so it will show + # on the display + main_group.append(output_lbl) + +# lists for mouse interface indexes, endpoint addresses, and USB Device instances +# each of these will end up with length 2 once we find both mice +mouse_interface_indexes = [] +mouse_endpoint_addresses = [] +mice = [] + +# scan for connected USB devices +for device in usb.core.find(find_all=True): + # check for boot mouse endpoints on this device + mouse_interface_index, mouse_endpoint_address = ( + adafruit_usb_host_descriptors.find_boot_mouse_endpoint(device) + ) + # if a boot mouse interface index and endpoint address were found + if mouse_interface_index is not None and mouse_endpoint_address is not None: + # add the interface index to the list of indexes + mouse_interface_indexes.append(mouse_interface_index) + # add the endpoint address to the list of addresses + mouse_endpoint_addresses.append(mouse_endpoint_address) + # add the device instance to the list of mice + mice.append(device) + + # print details to the console + print(f"mouse interface: {mouse_interface_index} ", end="") + print(f"endpoint_address: {hex(mouse_endpoint_address)}") + + # detach device from kernel if needed + if device.is_kernel_driver_active(0): + device.detach_kernel_driver(0) + + # set the mouse configuration so it can be used + device.set_configuration() + +# This is ordered by bit position. +BUTTONS = ["left", "right", "middle"] + +# list of buffers, will hold one buffer for each mouse +mouse_bufs = [] +for i in range(2): + # Buffer to hold data read from the mouse + mouse_bufs.append(array.array("b", [0] * 8)) + + +def get_mouse_deltas(buffer, read_count): + """ + Given a buffer and read_count return the x and y delta values + :param buffer: A buffer containing data read from the mouse + :param read_count: How many bytes of data were read from the mouse + :return: tuple x,y delta values + """ + if read_count == 4: + delta_x = buffer[1] + delta_y = buffer[2] + elif read_count == 8: + delta_x = buffer[2] + delta_y = buffer[4] + else: + raise ValueError(f"Unsupported mouse packet size: {read_count}, must be 4 or 8") + return delta_x, delta_y + +# main loop +while True: + # for each mouse instance + for mouse_index, mouse in enumerate(mice): + # try to read data from the mouse + try: + count = mouse.read( + mouse_endpoint_addresses[mouse_index], mouse_bufs[mouse_index], timeout=10 + ) + + # if there is no data it will raise USBTimeoutError + except usb.core.USBTimeoutError: + # Nothing to do if there is no data for this mouse + continue + + # there was mouse data, so get the delta x and y values from it + mouse_deltas = get_mouse_deltas(mouse_bufs[mouse_index], count) + + # update the x position of this mouse cursor using the delta value + # clamped to the display size + mouse_tgs[mouse_index].x = max( + 0, min(display.width - 1, mouse_tgs[mouse_index].x + mouse_deltas[0]) + ) + # update the y position of this mouse cursor using the delta value + # clamped to the display size + mouse_tgs[mouse_index].y = max( + 0, min(display.height - 1, mouse_tgs[mouse_index].y + mouse_deltas[1]) + ) + + # output string with the new cursor position + out_str = f"{mouse_tgs[mouse_index].x},{mouse_tgs[mouse_index].y}" + + # loop over possible button bit indexes + for i, button in enumerate(BUTTONS): + # check each bit index to determin if the button was pressed + if mouse_bufs[mouse_index][0] & (1 << i) != 0: + # if it was pressed, add the button to the output string + out_str += f" {button}" + + # set the output string into text of the label + # to show it on the display + output_lbls[mouse_index].text = out_str diff --git a/Metro/Metro_RP2350_Match3/two_mice_demo/mouse_cursor.bmp b/Metro/Metro_RP2350_Match3/two_mice_demo/mouse_cursor.bmp new file mode 100644 index 000000000..94ec32889 Binary files /dev/null and b/Metro/Metro_RP2350_Match3/two_mice_demo/mouse_cursor.bmp differ