Merge pull request #3045 from makermelissa/main

Add 3-in-a-row tile matching game
This commit is contained in:
Melissa LeBlanc-Williams 2025-05-29 14:46:54 -07:00 committed by GitHub
commit a87f6abb0b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 801 additions and 0 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 B

View file

@ -0,0 +1,269 @@
# 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
# 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")
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()

View file

@ -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

View file

@ -0,0 +1,491 @@
# 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
@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 = []
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
@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)
# 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