From d70fa564441a52b320b6a450fa6d1bca57eec815 Mon Sep 17 00:00:00 2001 From: Melissa LeBlanc-Williams Date: Thu, 29 May 2025 14:30:36 -0700 Subject: [PATCH 1/2] Add 3-in-a-row tile matching game --- .../bitmaps/foreground.bmp | Bin 0 -> 153656 bytes .../bitmaps/game_sprites.bmp | Bin 0 -> 12412 bytes .../bitmaps/mouse_cursor.bmp | Bin 0 -> 128 bytes Metro/Metro_RP2350_Matching_Game/code.py | 262 ++++++++++ .../Metro_RP2350_Matching_Game/eventbutton.py | 41 ++ Metro/Metro_RP2350_Matching_Game/gamelogic.py | 489 ++++++++++++++++++ 6 files changed, 792 insertions(+) create mode 100755 Metro/Metro_RP2350_Matching_Game/bitmaps/foreground.bmp create mode 100755 Metro/Metro_RP2350_Matching_Game/bitmaps/game_sprites.bmp create mode 100755 Metro/Metro_RP2350_Matching_Game/bitmaps/mouse_cursor.bmp create mode 100755 Metro/Metro_RP2350_Matching_Game/code.py create mode 100755 Metro/Metro_RP2350_Matching_Game/eventbutton.py create mode 100755 Metro/Metro_RP2350_Matching_Game/gamelogic.py diff --git a/Metro/Metro_RP2350_Matching_Game/bitmaps/foreground.bmp b/Metro/Metro_RP2350_Matching_Game/bitmaps/foreground.bmp new file mode 100755 index 0000000000000000000000000000000000000000..a546be7af4fbf3041f1ff7d7d2d40fcab05e2613 GIT binary patch literal 153656 zcmeI$F=`u807cQU3N-;?)J>@_jlfmN6oQaG<$xBV-DnvqwF)&_N0t!TAaFJ?H{@P_ zMq}*a`8^K5@Xuf0kE`MR>)Ye``gnf448!lo`(=22d=A6v@%!zU;p6%E!Qa(q2?%nMwO^WnTc?Uog|-JjRl zGrRlT{c=37m%Rh;<9zu17xcY*f%$MBm%IU|O}C$0+~4gl$7!>qzw7Fm|NO6GZ|4F} z2iH~poC4U^`D6FDr{~WufbE|@c7J<%{_FzS{`q6~x2NaNE`aTyKX!k6dj9MJ*#7xr z_qV6#&n|%NpFehgdwTxt0@(ifWB0eG=g%&H?Vmq(e|viV>;l;S`D6FDr{~WufbE|@ zc7J<%{_FzS{`q6~x2NaNE`aTyKX!k6dj9MJ*#7xr_qV6#&n|%NpFehgdwTxt0@(if zWB0eG=g%&H?Vmq(e|viV>;l;S`D6FDr{~WufbE|@c7J<%{_FzS{`q6~x2NaNE`aTy zKX!k6dj9MJ*#7xr_qV6#&n|%NpFehgdwTxt0@(ifWB0eG=g%&H?Vmq(e|viV>;l;S z`D6FDr{~WufbE|@c7J<%{_FzS{`q6~x2NaNE`aTyKX!k6dj9MJ*#7xr_qV6#&n|%N zpFehgdwTxt0@(ifWB0eG=g%&H?Vmq(e|viV>;l;S`D6FDr{~WufbE|@c7J<%{_FzS z{`q6~x2NaNE`aTyKX!k6dj9MJ*#7xr_qV6#&n|%NpFehgdwTxt0@(ifWB0eG=g%&H z?Vmq(e|viV>;l;S`D6FDr{~WufbE|@c7J<%{_FzS{`q6~x2NaNE`aTyKX!k6dj9MJ z*#7xr_qV6#&n|%NpFehgdwTxt0@(ifWB0eG=g%&H?Vmq(e|viV>;l;S`D6FDr{~Wu zfbE|@c7J<%{_FzS{`q6~x2NaNE`aTyKX!k6dj9MJ*#7xr_qV6#&n|%NpFehgdwTxt z0@(ifWB0eG=g%&H?Vmq(e|viV>;l;S`D6FDr{~WufbE|@c7J<%{_FzS{`q6~x2NaN zE`aTyKX!k6dj9MJ*#7xr_qV6#&n|%NpFehgdwTxt0@(ifWB0eG=g%&H?Vmq(e|viV z>;l;S`D6FDr{~WufbE|@c7J<%{_FzS{`q6~x2NaNE`aTyKX!k6dj9MJ*#7xr_qV6# z&n|%NpFehgdwTxt0@(ifWB0eG=g%&H?Vmq(e|viV>;l;S`D6FDr{~WufbE|@c7J<% z{_FzS{`q6~x2NaNE`aTyKX!k6dj9MJ*#7xr_qV6#&n|%NpFehgdwTxt0@(ifWB0eG z=g%&H?Vmq(e|viV>;l;S`D6FDr{~WufbE|@c7J<%{_FzS{`q6~x2NaNE`aTyKX!k6 zdj9MJ*#7xr_qV6#&n|%NpFehgdwTxt0@(ifWB0eG=g%&H?Vmq(e|viV>;l;S`D6FD zr{~WufbE|@c7J<%{_FzS{`q6~x2NaNE`aTyKX!k6dj9MJ*#7xr_qV6#&n|%NpFehg zdwTxt0@(ifWB0eG=g%&H?Vmq(e|viV>;l;S`D6FDr{~WufbE|@c7J<%{_FzS{`q6~ zx2NaNE`aTyKX!k6dj9MJ*#7xr_qV6#&n|%NpFehgdwTxt0@(ifWB0eG=g%&H?Vmq( ze|viV>;l;S`D6FDr{~WufbE|@c7J<%{_FzS{`q6~x2NaNE`aTyKX!k6dj9MJ*#7xr z_qV6#&n|%NpFehgdwTxt0@(ifWB0eG=g%&H?a%m|Hkacwxpxa(j??D$w%oao^P$yS z;JLtjxR1-}yS;7q=XE#WbA9{!ay+lM`z8I|-cGyodOl<@d-LJEKJAv(_nx-h-^Xcl fU3JV0Oq=_-^xe0-^%7g41zMm5THs$_U>N=Y#z@_T literal 0 HcmV?d00001 diff --git a/Metro/Metro_RP2350_Matching_Game/bitmaps/game_sprites.bmp b/Metro/Metro_RP2350_Matching_Game/bitmaps/game_sprites.bmp new file mode 100755 index 0000000000000000000000000000000000000000..89ac3effb0fd76382bdba831326a7528b13b83c6 GIT binary patch literal 12412 zcmeI1yN={U6o#uW<4do1ZFqx~kk|;35F!u(83{1~55NN;L;xZnLqeoT1d#F;lkx_n zyZ`}d^Z#|~xaySMuHNn*&qz?aUAJS`cWza7_k8rp*Y7%CzC!&9pKtK_9G{JAP&>DU z{LN?bc>`m9?tePEU%vazy?XnVyMN~g_r-^wx!*r#Mq|#0xFL;7h^4g1BtAUIBDncXfsTzN-op00ptGUTWL6uG+4v+p6xW zszcL`8Bn_S7IN}I79Y?Jb$ieV++zLG8ZcN(E1gOMP`dRN@>ckge4$>`{`Nro`%RCg zLW4oIW&$ACNvR9aB@)*>We z+yMwa_y3xFJaE)@Y1gIWshgYt&;Z%Ls`*F&bpSK5f;(;<;^p;5zVEPFzW&xyJn_8) zK!yVjd<$B&fKSI`+fk20eE(QY3upj7@K9vc!!KCq4JRH7v}+!|>R~*Cm<8}ZK03K# z%ec2lJQ)8dd<36vf0W?kI|rT5^k4bOqXCA?53n7s$44jEZu!|y3P zbQ-J+9)!OgV7t=Ussg*pWg&PF{-9wO%Y!Biz;3q=-y1y)jDr8r0qCGv!~fphbi#yR z3vkwLR%$YTO>B=+yB%>2{)Z$q}hSM`hnv;1Z|fDa^# zw*oDVu^}f1F4K70mh^SOu(j3+>|(B>XJ-OZ}c!%TNA7{3SRy;nV%dzCc_hvL)x$#b6y1pf#89KTNdk3Rrd4Viz`Uh81)q!IpPy3D6j zH2!R7`8;`G!QA;1km7$ocww~g_N(`2{C@@?Xg&mdZPtzh!S8SgG|e2J6kn`&MY)pw zglmaN(+hm0z+myk^N%m=1^+9Xt)a$&gx}|&ef=o$NxvPxrUM3Z2n81h_viV~*AIiA z=AU^1h!)!({Es(@;Z8h|%9i<})}sH@CqTNe>HGNiRZlgHpyYqAK!HDcz^-gYOaQ)u zyJ@HLYiFJa|D+nh{s(iz{P&ya)&Ke47f;In z8}17E9|NT`5S~mXx!Q;?iT{(~o2YEmDXZCU4<{<)Rq*_-d-kmBy7)xE;BfF?@JIjS zhksMjV6y)rD=Jt2FYrMV{(#=|=crJ?rvf+P%k$ILqy3--KHnWtln(_)KL6;H6g4ll z*8XuG_HUX1-uJwy--jV3@IPq6^lYhg%JTIB=rOK8cDQmV=#JC0lDxeNrS&>cw%>z= z5sV%Z|A(o4YJXf|o*(6w4|){E9;TJgD>A-JrTlnj-_O~f%2WHX(#gWCq_5$gj4xB8 z8}SFYbp6-h3ww`mbsBeeGzmOE#6R_am`eFc_RagB$)zQ^(JR1jl?bpDbmUv6?D&}f z=%xG5{KRMQbM`CRw+Y{8KQ@)p+0x}Ga#iGqyD*{qKdy5f-={y3ER3WN#>hK){-u1V z+_$aNabA1aevoUev%VGTv^E!&21>qwujfyuL5XPP;N$zf)>g(5j~{Qpucz{BXGq4z z5A}34IsUYs%CDV4{t+MQ>1s&(Ue0&cQ~9+sm*@YrWV!#p&NnXrb1qHKzd8BUb1?$* hOGO3d(t5Aa_`RmJ^`xho}waowk literal 0 HcmV?d00001 diff --git a/Metro/Metro_RP2350_Matching_Game/bitmaps/mouse_cursor.bmp b/Metro/Metro_RP2350_Matching_Game/bitmaps/mouse_cursor.bmp new file mode 100755 index 0000000000000000000000000000000000000000..f941ad29b61157d4cdaee944c1342b47940bb15a GIT binary patch literal 128 zcmXAgF%E!02n6x8uru5X02?cxU~#>F^5^FemJGWj%XmL24eWv)4p?Mki#%}N#Y#?Q e&YYxFGZF8JHMIcebnW4)9gNIv)uJ7IVdw|;R10MQ literal 0 HcmV?d00001 diff --git a/Metro/Metro_RP2350_Matching_Game/code.py b/Metro/Metro_RP2350_Matching_Game/code.py new file mode 100755 index 000000000..c8a0b613e --- /dev/null +++ b/Metro/Metro_RP2350_Matching_Game/code.py @@ -0,0 +1,262 @@ +# SPDX-FileCopyrightText: 2025 Melissa LeBlanc-Williams for Adafruit Industries +# SPDX-License-Identifier: MIT +""" +An implementation of a match3 jewel swap game. The idea is to move one character at a time +to line up at least 3 characters. +""" +from displayio import Group, OnDiskBitmap, TileGrid, Bitmap, Palette +from adafruit_display_text.bitmap_label import Label +from adafruit_display_text.text_box import TextBox +from eventbutton import EventButton +import supervisor +import terminalio +from adafruit_usb_host_mouse import find_and_init_boot_mouse +from gamelogic import GameLogic, SELECTOR_SPRITE, EMPTY_SPRITE, GAMEBOARD_POSITION + +GAMEBOARD_SIZE = (8, 7) +HINT_TIMEOUT = 10 # seconds before hint is shown +GAME_PIECES = 7 # Number of different game pieces (set between 3 and 8) + +# pylint: disable=ungrouped-imports +if hasattr(supervisor.runtime, "display") and supervisor.runtime.display is not None: + # use the built-in HSTX display for Metro RP2350 + display = supervisor.runtime.display +else: + # pylint: disable=ungrouped-imports + from displayio import release_displays + import picodvi + import board + import framebufferio + + # initialize display + release_displays() + + fb = picodvi.Framebuffer( + 320, + 240, + clk_dp=board.CKP, + clk_dn=board.CKN, + red_dp=board.D0P, + red_dn=board.D0N, + green_dp=board.D1P, + green_dn=board.D1N, + blue_dp=board.D2P, + blue_dn=board.D2N, + color_depth=16, + ) + display = framebufferio.FramebufferDisplay(fb) + +def get_color_index(color, shader=None): + for index, palette_color in enumerate(shader): + if palette_color == color: + return index + return None + +# Load the spritesheet +sprite_sheet = OnDiskBitmap("/bitmaps/game_sprites.bmp") +sprite_sheet.pixel_shader.make_transparent( + get_color_index(0x00ff00, sprite_sheet.pixel_shader) +) + +# Main group will hold all the visual layers +main_group = Group() +display.root_group = main_group + +# Add Background to the Main Group +background = Bitmap(display.width, display.height, 1) +bg_color = Palette(1) +bg_color[0] = 0x333333 +main_group.append(TileGrid( + background, + pixel_shader=bg_color +)) + +# Add Game grid, which holds the game board, to the main group +game_grid = TileGrid( + sprite_sheet, + pixel_shader=sprite_sheet.pixel_shader, + width=GAMEBOARD_SIZE[0], + height=GAMEBOARD_SIZE[1], + tile_width=32, + tile_height=32, + x=GAMEBOARD_POSITION[0], + y=GAMEBOARD_POSITION[1], + default_tile=EMPTY_SPRITE, +) +main_group.append(game_grid) + +# Add a special selection groupd to highlight the selected piece and allow animation +selected_piece_group = Group() +selected_piece = TileGrid( + sprite_sheet, + pixel_shader=sprite_sheet.pixel_shader, + width=1, + height=1, + tile_width=32, + tile_height=32, + x=0, + y=0, + default_tile=EMPTY_SPRITE, +) +selected_piece_group.append(selected_piece) +selector = TileGrid( + sprite_sheet, + pixel_shader=sprite_sheet.pixel_shader, + width=1, + height=1, + tile_width=32, + tile_height=32, + x=0, + y=0, + default_tile=SELECTOR_SPRITE, +) +selected_piece_group.append(selector) +selected_piece_group.hidden = True +main_group.append(selected_piece_group) + +# Add a group for the swap piece to help with animation +swap_piece = TileGrid( + sprite_sheet, + pixel_shader=sprite_sheet.pixel_shader, + width=1, + height=1, + tile_width=32, + tile_height=32, + x=0, + y=0, + default_tile=EMPTY_SPRITE, +) +swap_piece.hidden = True +main_group.append(swap_piece) + +# Add foreground +foreground_bmp = OnDiskBitmap("/bitmaps/foreground.bmp") +foreground_bmp.pixel_shader.make_transparent(0) +foreground_tg = TileGrid(foreground_bmp, pixel_shader=foreground_bmp.pixel_shader) +foreground_tg.x = 0 +foreground_tg.y = 0 +main_group.append(foreground_tg) + +# Add a group for the UI Elements +ui_group = Group() +main_group.append(ui_group) + +# Create the game logic object +game_logic = GameLogic(display, game_grid, swap_piece, selected_piece_group, GAME_PIECES) # pylint: disable=no-value-for-parameter + +# Create the mouse graphics and add to the main group +mouse = find_and_init_boot_mouse("/bitmaps/mouse_cursor.bmp") +if mouse is None: + raise RuntimeError("No mouse found connected to USB Host") +main_group.append(mouse.tilegrid) + +def update_ui(): + # Update the UI elements with the current game state + score_label.text = f"Score:\n{game_logic.score}" + +waiting_for_release = False +game_over_shown = False + +# Create the UI Elements +# Label for the Score +score_label = Label( + terminalio.FONT, + color=0xffff00, + x=5, + y=10, +) +ui_group.append(score_label) + +message_dialog = Group() +message_dialog.hidden = True + +def reset(): + global game_over_shown # pylint: disable=global-statement + # Reset the game logic + game_logic.reset() + message_dialog.hidden = True + game_over_shown = False + +def hide_group(group): + group.hidden = True + +reset() + +reset_button = EventButton( + reset, + label="Reset", + width=40, + height=16, + x=5, + y=50, + style=EventButton.RECT, +) +ui_group.append(reset_button) + +message_label = TextBox( + terminalio.FONT, + text="", + color=0x333333, + background_color=0xEEEEEE, + width=display.width // 3, + height=90, + align=TextBox.ALIGN_CENTER, + padding_top=5, +) +message_label.anchor_point = (0, 0) +message_label.anchored_position = ( + display.width // 2 - message_label.width // 2, + display.height // 2 - message_label.height // 2, +) +message_dialog.append(message_label) +message_button = EventButton( + (hide_group, message_dialog), + label="OK", + width=40, + height=16, + x=display.width // 2 - 20, + y=display.height // 2 - message_label.height // 2 + 60, + style=EventButton.RECT, +) +message_dialog.append(message_button) +ui_group.append(message_dialog) + +# main loop +while True: + update_ui() + # update mouse + pressed_btns = mouse.update() + + if waiting_for_release and not pressed_btns: + # If both buttons are released, we can process the next click + waiting_for_release = False + + if not message_dialog.hidden: + if message_button.handle_mouse((mouse.x, mouse.y), + pressed_btns and "left" in pressed_btns, + waiting_for_release): + waiting_for_release = True + continue + + if reset_button.handle_mouse((mouse.x, mouse.y), + pressed_btns and "left" in pressed_btns, + waiting_for_release): + waiting_for_release = True + + # process gameboard click if no menu + game_board = game_logic.game_board + if (game_board.x <= mouse.x <= game_board.x + game_board.columns * 32 and + game_board.y <= mouse.y <= game_board.y + game_board.rows * 32 and + not waiting_for_release): + piece_coords = ((mouse.x - game_board.x) // 32, (mouse.y - game_board.y) // 32) + if pressed_btns and "left" in pressed_btns: + game_logic.piece_clicked(piece_coords) + waiting_for_release = True + game_over = game_logic.check_for_game_over() + if game_over and not game_over_shown: + message_label.text = ("No more moves available. your final score is:\n" + + str(game_logic.score)) + message_dialog.hidden = False + game_over_shown = True + if game_logic.time_since_last_update > HINT_TIMEOUT: + game_logic.show_hint() diff --git a/Metro/Metro_RP2350_Matching_Game/eventbutton.py b/Metro/Metro_RP2350_Matching_Game/eventbutton.py new file mode 100755 index 000000000..766cd9605 --- /dev/null +++ b/Metro/Metro_RP2350_Matching_Game/eventbutton.py @@ -0,0 +1,41 @@ +# SPDX-FileCopyrightText: 2025 Melissa LeBlanc-Williams for Adafruit Industries +# SPDX-License-Identifier: MIT + +from adafruit_button import Button + +class EventButton(Button): + """A button that can be used to trigger a callback when clicked. + + :param callback: The callback function to call when the button is clicked. + A tuple can be passed with an argument that will be passed to the + callback function. The first element of the tuple should be the + callback function, and the remaining elements will be passed as + arguments to the callback function. + """ + def __init__(self, callback, *args, **kwargs): + super().__init__(*args, **kwargs) + self.args = [] + self.selected = False + if isinstance(callback, tuple): + self.callback = callback[0] + self.args = callback[1:] + else: + self.callback = callback + + def click(self): + """Call the function when the button is pressed.""" + self.callback(*self.args) + + def handle_mouse(self, point, clicked, waiting_for_release): + if waiting_for_release: + return False + + # Handle mouse events for the button + if self.contains(point): + self.selected = True + if clicked: + self.click() + return True + else: + self.selected = False + return False diff --git a/Metro/Metro_RP2350_Matching_Game/gamelogic.py b/Metro/Metro_RP2350_Matching_Game/gamelogic.py new file mode 100755 index 000000000..916163069 --- /dev/null +++ b/Metro/Metro_RP2350_Matching_Game/gamelogic.py @@ -0,0 +1,489 @@ +# SPDX-FileCopyrightText: 2025 Melissa LeBlanc-Williams for Adafruit Industries +# SPDX-License-Identifier: MIT + +import random +import time +from adafruit_ticks import ticks_ms + +GAMEBOARD_POSITION = (55, 8) + +SELECTOR_SPRITE = 9 +EMPTY_SPRITE = 10 +DEBOUNCE_TIME = 0.1 # seconds for debouncing mouse clicks + +class GameBoard: + "Contains the game board" + def __init__(self, game_grid, swap_piece, selected_piece_group): + self.x = GAMEBOARD_POSITION[0] + self.y = GAMEBOARD_POSITION[1] + self._game_grid = game_grid + self._selected_coords = None + self._selected_piece = selected_piece_group[0] + self._selector = selected_piece_group[1] + self._swap_piece = swap_piece + self.selected_piece_group = selected_piece_group + + def add_game_piece(self, column, row, piece_type): + if 0 <= column < self.columns and 0 <= row < self.rows: + if self._game_grid[(column, row)] != EMPTY_SPRITE: + raise ValueError("Position already occupied") + self._game_grid[(column, row)] = piece_type + else: + raise IndexError("Position out of bounds") + + def remove_game_piece(self, column, row): + if 0 <= column < self.columns and 0 <= row < self.rows: + self._game_grid[(column, row)] = EMPTY_SPRITE + else: + raise IndexError("Position out of bounds") + + def reset(self): + for column in range(self.columns): + for row in range(self.rows): + if self._game_grid[(column, row)] != EMPTY_SPRITE: + self.remove_game_piece(column, row) + + def move_game_piece(self, old_x, old_y, new_x, new_y): + if 0 <= old_x < self.columns and 0 <= old_y < self.rows: + if 0 <= new_x < self.columns and 0 <= new_y < self.rows: + if self._game_grid[(new_x, new_y)] == EMPTY_SPRITE: + self._game_grid[(new_x, new_y)] = self._game_grid[(old_x, old_y)] + self._game_grid[(old_x, old_y)] = EMPTY_SPRITE + else: + raise ValueError("New position already occupied") + else: + raise IndexError("New position out of bounds") + else: + raise IndexError("Old position out of bounds") + + @property + def columns(self): + return self._game_grid.width + + @property + def rows(self): + return self._game_grid.height + + @property + def selected_piece(self): + if self._selected_coords is not None and self._selected_piece[0] != EMPTY_SPRITE: + return self._selected_piece[0] + return None + + @property + def swap_piece(self): + return self._swap_piece + + def set_swap_piece(self, column, row): + # Set the swap piece to the piece at the specified coordinates + piece = self.get_piece(column, row) + if self._swap_piece[0] is None and self._swap_piece[0] == EMPTY_SPRITE: + raise ValueError("Can't swap an empty piece") + if self._swap_piece.hidden: + self._swap_piece[0] = piece + self._swap_piece.x = column * 32 + self.x + self._swap_piece.y = row * 32 + self.y + self._swap_piece.hidden = False + self._game_grid[(column, row)] = EMPTY_SPRITE + else: + self._game_grid[(column, row)] = self._swap_piece[0] + self._swap_piece[0] = EMPTY_SPRITE + self._swap_piece.hidden = True + + @property + def selected_coords(self): + if self._selected_coords is not None: + return self._selected_coords + return None + + @property + def selector_hidden(self): + return self._selector.hidden + + @selector_hidden.setter + def selector_hidden(self, value): + # Set the visibility of the selector + self._selector.hidden = value + + def set_selected_coords(self, column, row): + # Set the selected coordinates to the specified column and row + if 0 <= column < self.columns and 0 <= row < self.rows: + self._selected_coords = (column, row) + self.selected_piece_group.x = column * 32 + self.x + self.selected_piece_group.y = row * 32 + self.y + else: + raise IndexError("Selected coordinates out of bounds") + + def select_piece(self, column, row, show_selector=True): + # Take care of selecting a piece + piece = self.get_piece(column, row) + if self.selected_piece is None and piece == EMPTY_SPRITE: + # If no piece is selected and the clicked piece is empty, do nothing + return + + if (self.selected_piece is not None and + (self._selected_coords[0] != column or self._selected_coords[1] != row)): + # If a piece is already selected and the coordinates don't match, do nothing + return + + if self.selected_piece is None: + # No piece selected, so select the specified piece + self._selected_piece[0] = self.get_piece(column, row) + self._selected_coords = (column, row) + self.selected_piece_group.x = column * 32 + self.x + self.selected_piece_group.y = row * 32 + self.y + self.selected_piece_group.hidden = False + self.selector_hidden = not show_selector + self._game_grid[(column, row)] = EMPTY_SPRITE + else: + self._game_grid[(column, row)] = self._selected_piece[0] + self._selected_piece[0] = EMPTY_SPRITE + self.selected_piece_group.hidden = True + self._selected_coords = None + + def get_piece(self, column, row): + if 0 <= column < self.columns and 0 <= row < self.rows: + return self._game_grid[(column, row)] + return None + + @property + def game_grid_copy(self): + # Return a copy of the game grid as a 2D list + return [[self._game_grid[(x, y)] for x in range(self.columns)] for y in range(self.rows)] + +class GameLogic: + "Contains the Logic to examine the game board and determine if a move is valid." + def __init__(self, display, game_grid, swap_piece, selected_piece_group, game_pieces): + self._display = display + self.game_board = GameBoard(game_grid, swap_piece, selected_piece_group) + self._score = 0 + self._available_moves = [] + if not 3 <= game_pieces <= 8: + raise ValueError("game_pieces must be between 3 and 8") + self._game_pieces = game_pieces # Number of different game pieces + self._last_update_time = ticks_ms() # For hint timing + self._last_click_time = ticks_ms() # For debouncing mouse clicks + + def piece_clicked(self, coords): + """ Handle a piece click event. """ + if ticks_ms() <= self._last_click_time: + self._last_click_time -= 2**29 # ticks_ms() wraps around after 2**29 ms + + if ticks_ms() <= self._last_click_time + (DEBOUNCE_TIME * 1000): + print("Debouncing click, too soon after last click.") + return + self._last_click_time = ticks_ms() # Update last click time + + column, row = coords + self._last_update_time = ticks_ms() + # Check if the clicked piece is valid + if not 0 <= column < self.game_board.columns or not 0 <= row < self.game_board.rows: + print(f"Clicked coordinates ({column}, {row}) are out of bounds.") + return + + # If clicked piece is empty and no piece is selected, do nothing + if (self.game_board.get_piece(column, row) == EMPTY_SPRITE and + self.game_board.selected_piece is None): + print(f"No piece at ({column}, {row}) and no piece selected.") + return + + if self.game_board.selected_piece is None: + # If no piece is selected, select the piece at the clicked coordinates + self.game_board.select_piece(column, row) + return + + if (self.game_board.selected_coords is not None and + (self.game_board.selected_coords[0] == column and + self.game_board.selected_coords[1] == row)): + # If the clicked piece is already selected, deselect it + self.game_board.select_piece(column, row) + return + + # If piece is selected and the new coordinates are 1 position + # away horizontally or vertically, swap the pieces + if self.game_board.selected_coords is not None: + previous_x, previous_y = self.game_board.selected_coords + if ((abs(previous_x - column) == 1 and previous_y == row) or + (previous_x == column and abs(previous_y - row) == 1)): + # Swap the pieces + self.swap_selected_piece(column, row) + + def show_hint(self): + """ Show a hint by selecting a random available + move and swapping the pieces back and forth. """ + if self._available_moves: + move = random.choice(self._available_moves) + from_coords = move['from'] + to_coords = move['to'] + self.game_board.select_piece(from_coords[0], from_coords[1]) + self.animate_swap(to_coords[0], to_coords[1]) + self.game_board.select_piece(from_coords[0], from_coords[1]) + self.animate_swap(to_coords[0], to_coords[1]) + self._last_update_time = ticks_ms() # Reset hint timer + + def swap_selected_piece(self, column, row): + """ Swap the selected piece with the piece at the specified column and row. + If the swap is not valid, revert to the previous selection. """ + old_coords = self.game_board.selected_coords + self.animate_swap(column, row) + if not self.update(): + self.game_board.select_piece(column, row, show_selector=False) + self.animate_swap(old_coords[0], old_coords[1]) + + def animate_swap(self, column, row): + """ Copy the pieces to separate tilegrids, animate the swap, and update the game board. """ + if 0 <= column < self.game_board.columns and 0 <= row < self.game_board.rows: + selected_coords = self.game_board.selected_coords + if selected_coords is None: + print("No piece selected to swap.") + return + + # Set the swap piece value to the column, row value + self.game_board.set_swap_piece(column, row) + self.game_board.selector_hidden = True + + # Calculate the steps for animation to move the pieces in the correct direction + selected_piece_steps = ( + (self.game_board.swap_piece.x - self.game_board.selected_piece_group.x) // 32, + (self.game_board.swap_piece.y - self.game_board.selected_piece_group.y) // 32 + ) + swap_piece_steps = ( + (self.game_board.selected_piece_group.x - self.game_board.swap_piece.x) // 32, + (self.game_board.selected_piece_group.y - self.game_board.swap_piece.y) // 32 + ) + + # Move the tilegrids in small steps to create an animation effect + for _ in range(32): + # Move the selected piece tilegrid to the swap piece position + self.game_board.selected_piece_group.x += selected_piece_steps[0] + self.game_board.selected_piece_group.y += selected_piece_steps[1] + # Move the swap piece tilegrid to the selected piece position + self.game_board.swap_piece.x += swap_piece_steps[0] + self.game_board.swap_piece.y += swap_piece_steps[1] + time.sleep(0.002) + + # Set the existing selected piece coords to the swap piece value + self.game_board.set_swap_piece(selected_coords[0], selected_coords[1]) + + # Update the selected piece coordinates to the new column, row + self.game_board.set_selected_coords(column, row) + + # Deselect the selected piece (which sets the value) + self.game_board.select_piece(column, row) + + def apply_gravity(self): + """ Go through each column from the bottom up and move pieces down + continue until there are no more pieces to move """ + # pylint:disable=too-many-nested-blocks + while True: + moved = False + for x in range(self.game_board.columns): + for y in range(self.game_board.rows - 1, -1, -1): + piece = self.game_board.get_piece(x, y) + if piece != EMPTY_SPRITE: + # Check if the piece can fall + for new_y in range(y + 1, self.game_board.rows): + if self.game_board.get_piece(x, new_y) == EMPTY_SPRITE: + # Move the piece down + self.game_board.move_game_piece(x, y, x, new_y) + moved = True + break + # If the piece was in the top slot before falling, add a new piece + if y == 0 and self.game_board.get_piece(x, 0) == EMPTY_SPRITE: + self.game_board.add_game_piece(x, 0, random.randint(0, self._game_pieces)) + moved = True + if not moved: + break + + def check_for_matches(self): + """ Scan the game board for matches of 3 or more in a row or column """ + matches = [] + for x in range(self.game_board.columns): + for y in range(self.game_board.rows): + piece = self.game_board.get_piece(x, y) + if piece != EMPTY_SPRITE: + # Check horizontal matches + horizontal_match = [(x, y)] + for dx in range(1, 3): + if (x + dx < self.game_board.columns and + self.game_board.get_piece(x + dx, y) == piece): + horizontal_match.append((x + dx, y)) + else: + break + if len(horizontal_match) >= 3: + matches.append(horizontal_match) + + # Check vertical matches + vertical_match = [(x, y)] + for dy in range(1, 3): + if (y + dy < self.game_board.rows and + self.game_board.get_piece(x, y + dy) == piece): + vertical_match.append((x, y + dy)) + else: + break + if len(vertical_match) >= 3: + matches.append(vertical_match) + return matches + + def update(self): + """ Update the game logic, check for matches, and apply gravity. """ + matches_found = False + multiplier = 1 + matches = self.check_for_matches() + while matches: + if matches: + for match in matches: + for x, y in match: + self.game_board.remove_game_piece(x, y) + self._score += 10 * multiplier * len(matches) * (len(match) - 2) + time.sleep(0.5) # Pause to show the match removal + self.apply_gravity() + matches_found = True + matches = self.check_for_matches() + multiplier += 1 + self._available_moves = self.find_all_possible_matches() + print(f"{len(self._available_moves)} available moves found.") + return matches_found + + def reset(self): + """ Reset the game board and score. """ + self.game_board.reset() + self._score = 0 + self._last_update_time = ticks_ms() + self.apply_gravity() + self.update() + + def check_match_after_move(self, row, column, direction, move_type='horizontal'): + """ Move the piece in a copy of the board to see if it creates a match.""" + if move_type == 'horizontal': + new_row, new_column = row, column + direction + else: # vertical + new_row, new_column = row + direction, column + + # Check if move is within bounds + if (new_row < 0 or new_row >= self.game_board.rows or + new_column < 0 or new_column >= self.game_board.columns): + return False, False + + # Create a copy of the grid with the moved piece + new_grid = self.game_board.game_grid_copy + piece = new_grid[row][column] + new_grid[row][column], new_grid[new_row][new_column] = new_grid[new_row][new_column], piece + + # Check for horizontal matches at the new position + horizontal_match = self.check_horizontal_match(new_grid, new_row, new_column, piece) + + # Check for vertical matches at the new position + vertical_match = self.check_vertical_match(new_grid, new_row, new_column, piece) + + # Also check the original position for matches after the swap + original_piece = new_grid[row][column] + horizontal_match_orig = self.check_horizontal_match(new_grid, row, column, original_piece) + vertical_match_orig = self.check_vertical_match(new_grid, row, column, original_piece) + + all_matches = (horizontal_match + vertical_match + + horizontal_match_orig + vertical_match_orig) + + return True, len(all_matches) > 0 + + def check_horizontal_match(self, grid, row, column, piece): + """Check for horizontal 3-in-a-row matches centered + around or including the given position.""" + matches = [] + columns = len(grid[0]) + + # Check all possible 3-piece horizontal combinations that include this position + for start_column in range(max(0, column - 2), min(columns - 2, column + 1)): + if (start_column + 2 < columns and + grid[row][start_column] == piece and + grid[row][start_column + 1] == piece and + grid[row][start_column + 2] == piece): + matches.append([(row, start_column), + (row, start_column + 1), + (row, start_column + 2)]) + + return matches + + def check_vertical_match(self, grid, row, column, piece): + """Check for vertical 3-in-a-row matches centered around or including the given position.""" + matches = [] + rows = len(grid) + + # Check all possible 3-piece vertical combinations that include this position + for start_row in range(max(0, row - 2), min(rows - 2, row + 1)): + if (start_row + 2 < rows and + grid[start_row][column] == piece and + grid[start_row + 1][column] == piece and + grid[start_row + 2][column] == piece): + matches.append([(start_row, column), + (start_row + 1, column), + (start_row + 2, column)]) + + return matches + + def check_for_game_over(self): + """ Check if there are no available moves left on the game board. """ + if not self._available_moves: + return True + return False + + def find_all_possible_matches(self): + """ + Scan the entire game board to find all possible moves that would create a 3-in-a-row match. + """ + possible_moves = [] + + for row in range(self.game_board.rows): + for column in range(self.game_board.columns): + # Check move right + can_move, creates_match = self.check_match_after_move(row, column, 1, 'horizontal') + if can_move and creates_match: + possible_moves.append({ + 'from': (column, row), + 'to': (column + 1, row), + }) + + # Check move left + can_move, creates_match = self.check_match_after_move(row, column, -1, 'horizontal') + if can_move and creates_match: + possible_moves.append({ + 'from': (column, row), + 'to': (column - 1, row), + }) + + # Check move down + can_move, creates_match = self.check_match_after_move(row, column, 1, 'vertical') + if can_move and creates_match: + possible_moves.append({ + 'from': (column, row), + 'to': (column, row + 1), + }) + + # Check move up + can_move, creates_match = self.check_match_after_move(row, column, -1, 'vertical') + if can_move and creates_match: + possible_moves.append({ + 'from': (column, row), + 'to': (column, row - 1), + }) + + # Remove duplicates because from and to can be reversed + unique_moves = set() + for move in possible_moves: + from_coords = tuple(move['from']) + to_coords = tuple(move['to']) + if from_coords > to_coords: + unique_moves.add((to_coords, from_coords)) + else: + unique_moves.add((from_coords, to_coords)) + possible_moves = [{'from': move[0], 'to': move[1]} for move in unique_moves] + + return possible_moves + + @property + def score(self): + return self._score + + @property + def time_since_last_update(self): + return (ticks_ms() - self._last_update_time) / 1000.0 From 08dc8367c568cb70d9a01347eb92e54fbcb7ab80 Mon Sep 17 00:00:00 2001 From: Melissa LeBlanc-Williams Date: Thu, 29 May 2025 14:43:32 -0700 Subject: [PATCH 2/2] Fix pylint issues --- Metro/Metro_RP2350_Matching_Game/code.py | 9 ++++++++- Metro/Metro_RP2350_Matching_Game/gamelogic.py | 6 ++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/Metro/Metro_RP2350_Matching_Game/code.py b/Metro/Metro_RP2350_Matching_Game/code.py index c8a0b613e..79aad98f3 100755 --- a/Metro/Metro_RP2350_Matching_Game/code.py +++ b/Metro/Metro_RP2350_Matching_Game/code.py @@ -142,7 +142,14 @@ def get_color_index(color, shader=None): main_group.append(ui_group) # Create the game logic object -game_logic = GameLogic(display, game_grid, swap_piece, selected_piece_group, GAME_PIECES) # pylint: disable=no-value-for-parameter +# pylint: disable=no-value-for-parameter, too-many-function-args +game_logic = GameLogic( + display, + game_grid, + swap_piece, + selected_piece_group, + GAME_PIECES +) # Create the mouse graphics and add to the main group mouse = find_and_init_boot_mouse("/bitmaps/mouse_cursor.bmp") diff --git a/Metro/Metro_RP2350_Matching_Game/gamelogic.py b/Metro/Metro_RP2350_Matching_Game/gamelogic.py index 916163069..c0ec7da33 100755 --- a/Metro/Metro_RP2350_Matching_Game/gamelogic.py +++ b/Metro/Metro_RP2350_Matching_Game/gamelogic.py @@ -386,7 +386,8 @@ def check_match_after_move(self, row, column, direction, move_type='horizontal') return True, len(all_matches) > 0 - def check_horizontal_match(self, grid, row, column, piece): + @staticmethod + def check_horizontal_match(grid, row, column, piece): """Check for horizontal 3-in-a-row matches centered around or including the given position.""" matches = [] @@ -404,7 +405,8 @@ def check_horizontal_match(self, grid, row, column, piece): return matches - def check_vertical_match(self, grid, row, column, piece): + @staticmethod + def check_vertical_match(grid, row, column, piece): """Check for vertical 3-in-a-row matches centered around or including the given position.""" matches = [] rows = len(grid)