Merge pull request #3019 from FoamyGuy/metro_match3_game

Metro match3 game
This commit is contained in:
foamyguy 2025-04-11 14:23:00 -05:00 committed by GitHub
commit dceed82785
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1722 additions and 0 deletions

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 922 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 922 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 922 B

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 B