1. 项目概述一个嵌入式平台上的经典记忆配对游戏如果你玩过那种翻牌配对的记忆游戏现在我们可以把它搬到一块小小的嵌入式开发板上用CircuitPython来实现。这不仅仅是把游戏逻辑移植过来那么简单它涉及到在资源受限的微控制器上如何高效地管理图形界面、处理用户输入以及组织复杂的游戏状态流转。我这次做的就是基于Adafruit Metro RP2350这类支持CircuitPython的开发板配合一块通过HSTX接口驱动的显示屏和一个USB鼠标完整复现了这个经典游戏。这个项目的核心价值在于它提供了一个非常典型的嵌入式交互应用范例。你不再只是点个灯、读个传感器数据而是构建了一个完整的、带图形界面和实时交互的应用。其中状态机是控制游戏流程的大脑它清晰地定义了“标题画面”、“游戏进行中”和“游戏结束”这三个阶段让代码逻辑变得条理分明。TileGrid则是渲染游戏画面的高效工具它允许我们使用一张包含所有卡牌图案的“精灵图”通过索引来快速切换显示内容极大地节省了内存和CPU资源。而GridLayout则负责自动排列这24张卡牌让我们从繁琐的坐标计算中解放出来。整个开发过程就是如何将这些独立的模块——显示驱动、输入处理、图形管理、游戏逻辑——优雅地整合在一起。下面我就带你一步步拆解这个项目的实现细节从硬件连接到代码的每一行分享其中踩过的坑和总结出的技巧。2. 硬件与开发环境搭建在动手写代码之前得先把舞台搭好。这个项目的硬件需求很明确一块能跑CircuitPython 8.x及以上版本、并且带USB Host功能的主控板如Metro RP2350一个用于显示的屏幕通过HSTX转DVI/HDMI以及一个最普通的USB鼠标。2.1 核心硬件选型与连接主控板我选用的是Adafruit Metro RP2350原因很简单它的RP2350芯片性能足够强劲自带USB Host端口并且有现成的HSTX接口用于驱动高清显示。当然理论上任何支持displayio、usb.core库且具备USB Host能力的CircuitPython板子都能跑但RP2350的社区支持和文档是最全的。注意务必确认你的开发板固件版本支持usb.core库。早期的CircuitPython版本可能没有完整的USB主机功能支持。刷写最新版CircuitPython固件通常是第一步。显示屏方面你需要一块支持DVI或HDMI输入的显示器或电视。连接线是标准的HDMI线但中间需要一个“HSTX to HDMI”转接板。这个转接板的作用是将主控板输出的数字视频信号转换成显示器能识别的标准HDMI信号。连接时注意方向HSTX接口有防呆设计一般不会插反。USB鼠标就随便了只要是标准HID协议的鼠标都行有线无线均可。我测试过几个不同品牌的包括一些老式的滚球鼠标兼容性都没问题。关键在于鼠标必须插在板子标记为“USB Host”的那个端口上而不是用来编程的USB-C口。2.2 软件环境与关键配置代码编辑可以用任何你喜欢的工具比如Mu Editor、VS Code with CircuitPython插件甚至简单的文本编辑器都行。因为最终我们是要把code.py和依赖库直接放到板子的CIRCUITPY磁盘里。有几个关键的配置项必须在settings.toml文件中设置这个文件位于CIRCUITPY盘的根目录。如果不存在就自己创建一个。# CIRCUITPY 根目录下的 settings.toml 文件内容 CIRCUITPY_DISPLAY_WIDTH 320 CIRCUITPY_DISPLAY_HEIGHT 240 CIRCUITPY_DISPLAY_BUS None第一行CIRCUITPY_DISPLAY_WIDTH 320至关重要。我们的游戏逻辑和素材都是基于320x240的分辨率设计的。虽然HSTX输出可能会被硬件倍频到640x480但displayio库依然需要知道我们逻辑上的帧缓冲区大小。如果不设置这一项supervisor.runtime.display可能无法正确初始化或者获取到错误的屏幕尺寸导致所有界面元素位置错乱。CIRCUITPY_DISPLAY_BUS None这一行是告诉系统我们不是通过SPI或I2C这类总线来驱动屏幕而是使用像HSTX这样的“内置”显示接口。对于RP2350这通常是必要的。2.3 依赖库管理与文件结构游戏用到了几个非核心库需要手动放入CIRCUITPY/lib目录。你可以通过Adafruit的CircuitPython库包Bundle来获取它们。具体需要以下库文件adafruit_displayio_layout/adafruit_display_text/adafruit_ticks.mpyadafruit_usb_host_descriptors.mpyadafruit_pathlib.mpy把整个adafruit_displayio_layout和adafruit_display_text文件夹复制到lib下其他.mpy文件直接放在lib根目录。最终你的CIRCUITPY磁盘文件结构应该大致如下CIRCUITPY/ ├── settings.toml ├── code.py ├── lib/ │ ├── adafruit_displayio_layout/ │ ├── adafruit_display_text/ │ ├── adafruit_ticks.mpy │ ├── adafruit_usb_host_descriptors.mpy │ └── adafruit_pathlib.mpy ├── memory_game_sprites.bmp ├── memory_title.bmp ├── btn_play_again.bmp ├── btn_exit.bmp └── mouse_cursor.bmp图片资源文件.bmp需要和code.py放在同一级目录。CircuitPython的OnDiskBitmap默认从当前工作目录加载图片。确保这些图片是索引色位图并且颜色深度与代码中使用的调色板匹配。我通常用GIMP或Aseprite来制作保存时选择“索引色”模式颜色数控制在256色以内以节省内存。3. 游戏核心架构与状态机设计写嵌入式游戏和写PC游戏的一个很大不同是你得非常小心地管理有限的内存和CPU周期。你不能开一堆线程也不能指望有强大的垃圾回收。状态机State Machine在这里就成了管理复杂流程的利器。它的思想很简单游戏在任何时刻都处于一个明确的、有限的状态之一并且根据特定事件比如鼠标点击在这些状态间切换。3.1 状态定义与流转逻辑在这个记忆游戏中我定义了三个核心状态用简单的整数常量表示STATE_TITLE 0 # 标题画面等待开始 STATE_PLAYING 1 # 游戏进行中 STATE_GAMEOVER 2 # 游戏结束显示结果 CUR_STATE STATE_TITLE # 当前状态初始为标题画面这个设计看似简单却极大地简化了主循环的逻辑。在主循环的每一次迭代中我们只需要检查CUR_STATE的值然后执行对应状态下的代码块。比如只有在STATE_PLAYING状态下我们才需要处理卡牌的点击和匹配判断在STATE_GAMEOVER状态下则只关心“再玩一次”或“退出”按钮的点击。状态转换的触发条件也很清晰从STATE_TITLE到STATE_PLAYING在标题画面任意位置点击鼠标左键。从STATE_PLAYING到STATE_GAMEOVER当所有卡牌配对成功即玩家总得分等于卡牌对数时。从STATE_GAMEOVER到STATE_TITLE点击“再玩一次”按钮实际是通过软重启supervisor.reload()重新加载代码回到初始状态。从STATE_GAMEOVER退出点击“退出”按钮同样是supervisor.reload()但这里假设回到默认的code.py你可以设计更复杂的逻辑。实操心得使用整数常量而不是字符串来定义状态效率更高因为整数比较比字符串比较快得多。用CUR_STATE这样的全局变量来跟踪状态虽然简单直接但在更复杂的项目中可以考虑将状态和状态转换逻辑封装成一个类这样可读性和可维护性会更好。3.2 主循环与事件分发有了状态机主循环就变得非常整洁。它只做四件事1) 读取输入鼠标2) 更新游戏逻辑根据当前状态3) 更新显示4) 等待下一帧如果需要控制帧率。在我们的游戏里因为事件驱动性很强主要响应点击所以没有用固定的帧率循环而是用一个while True循环不断检查。while True: # 1. 读取鼠标数据 try: data_len mouse.read(mouse_endpoint_address, buf, timeout20) # ... 更新鼠标光标位置 except usb.core.USBTimeoutError: pass # 没读到数据是正常的 # 2. 根据当前状态执行不同逻辑 if CUR_STATE STATE_TITLE: # 处理标题画面点击 if 鼠标左键按下: CUR_STATE STATE_PLAYING # ... 隐藏标题画面等操作 elif CUR_STATE STATE_PLAYING: # 处理游戏逻辑详见后文 pass elif CUR_STATE STATE_GAMEOVER: # 处理结束画面按钮点击 pass这种结构的好处是隔离性好。PLAYING状态下的复杂逻辑不会干扰到TITLE状态的简单展示。调试的时候也方便你可以很容易地知道问题出在哪个状态模块里。4. 图形系统TileGrid与GridLayout的深度应用在嵌入式设备上做图形尤其是动画直接操作像素是非常低效的。CircuitPython的displayio库提供了一套基于“图块”Tile的渲染系统TileGrid和GridLayout是其中的核心。4.1 精灵图Sprite Sheet与TileGrid原理所有卡牌的图案包括正面的8种图案、背面和空白都被做进了一张名为memory_game_sprites.bmp的位图里。这张图就是“精灵图”。想象一下老式的街机游戏或者早期RPG所有角色动作都放在一张大图上通过显示不同部分来形成动画原理是一样的。TileGrid对象的作用就是在这张大图上开一个“窗口”只显示其中一部分。我们为游戏中的24张卡牌创建了24个独立的TileGrid对象但它们都共享同一张精灵图位图数据。这比为每张卡牌都加载一个单独的图片文件要节省得多内存。创建TileGrid的关键参数new_tg TileGrid( bitmapsprites, # 共享的精灵图对象 default_tile10, # 默认显示的图块索引卡牌背面 tile_height32, # 每个图块的高度像素 tile_width32, # 每个图块的宽度像素 height1, width1, # 这个TileGrid由1x1个图块组成即只显示一个图案 pixel_shadersprites.pixel_shader, # 共享的调色板 )default_tile10意味着初始时这个TileGrid显示精灵图中索引为10的图块也就是卡牌背面。当玩家点击这张牌时我们只需要一句card_tg[0] target_index就能瞬间将显示切换到目标索引的图案卡牌正面实现“翻转”的视觉效果。这个操作只改变了一个索引值重绘由displayio底层自动完成效率极高。4.2 自动布局神器GridLayout手动计算24张卡牌在320x240屏幕上的位置x, y坐标是个噩梦而且难以维护。GridLayout就是为了解决这个问题而生的。你只需要告诉它容器的尺寸、网格的行列数然后把子元素我们的TileGrid加进去它就会自动帮你排列整齐。# 创建一个网格布局位于(10,10)宽260像素高200像素网格为6列4行 card_grid GridLayout(x10, y10, width260, height200, grid_size(6, 4))创建好布局容器后在嵌套循环中创建每个卡牌的TileGrid并通过add_content方法将其添加到网格的特定位置card_grid.add_content(new_tg, grid_position(x, y), cell_size(1, 1))这里的grid_position(x, y)指定了这张牌位于网格的第x列、第y行从0开始计数。cell_size(1, 1)表示这个元素占据1x1个网格单元格。GridLayout会根据容器总宽高和网格数自动计算出每个单元格的尺寸并将元素居中放置在其中。踩坑记录最初我尝试自己计算每个卡牌的位置代码里充满了x margin col * (card_width spacing)这样的公式。一旦想调整边距或间距就得改好几个地方。换成GridLayout后只需要调整容器的x, y, width, height所有卡牌会自动重新排列。布局的代码量减少了70%而且更清晰。强烈建议在需要规则排列UI元素时使用它。4.3 其他UI元素标签与按钮除了卡牌游戏界面还有得分标签、当前玩家指示器和游戏结束对话框。得分标签用的是adafruit_display_text.bitmap_label.Label它比普通的label渲染更快。关键点在于使用anchor_point和anchored_position进行定位。score_lbl.anchor_point (0, 0) # 将标签的左上角作为锚点 score_lbl.anchored_position (4, 1) # 将锚点放置在屏幕坐标(4, 1)的位置anchor_point是一个归一化坐标。(0,0)代表左上角(1.0, 1.0)代表右下角(0.5, 0.5)代表中心。通过组合锚点和定位可以轻松实现“左上角对齐”、“居中”、“右下角对齐”等常见布局而无需手动计算文本宽度。游戏结束时的“再玩一次”和“退出”按钮本质上也是TileGrid只不过它们的位图是按钮图片。检测点击的方法和检测卡牌点击一样都是使用TileGrid.contains(coords)方法来判断鼠标坐标是否在按钮的边界框内。5. 输入处理USB鼠标的读取与光标控制在嵌入式设备上使用USB鼠标听起来有点大材小用但对于需要精确点选的应用来说它比按键方便太多了。CircuitPython通过usb.core库提供了底层USB主机功能。5.1 鼠标设备的发现与初始化代码一开始会扫描所有连接的USB设备寻找符合“Boot Protocol Mouse”标准的设备。这是USB HID设备的一个标准子类绝大多数鼠标都兼容它。import usb.core import adafruit_usb_host_descriptors mouse None for device in usb.core.find(find_allTrue): mouse_interface_index, mouse_endpoint_address ( adafruit_usb_host_descriptors.find_boot_mouse_endpoint(device)) if mouse_interface_index is not None: mouse device breakfind_boot_mouse_endpoint这个辅助函数帮我们完成了繁琐的描述符解析工作直接返回鼠标数据所在的接口和端点地址。找到设备后我们需要“声明”这个设备的使用权特别是在Linux/Mac系统上需要先让内核驱动解除绑定。if mouse.is_kernel_driver_active(0): mouse_was_attached True mouse.detach_kernel_driver(0) mouse.set_configuration()atexit.register(atexit_callback)这行代码注册了一个退出回调函数。当程序结束比如板子复位时这个函数会被调用尝试将鼠标设备重新绑定回内核驱动。这是一个好习惯避免了程序崩溃后鼠标失控的情况。5.2 数据读取与光标移动Boot鼠标的报告描述符Report Descriptor通常是4个字节[按钮状态, X位移, Y位移, 滚轮]。我们创建一个4字节的数组作为缓冲区然后在一个短超时内尝试读取数据。buf array.array(b, [0] * 4) # 4字节缓冲区 try: data_len mouse.read(mouse_endpoint_address, buf, timeout20) if data_len 0: # buf[0]: 按钮位图 (bit0左键, bit1右键, bit2中键) # buf[1]: X轴相对位移 # buf[2]: Y轴相对位移 mouse_tg.x max(0, min(display.width - 1, mouse_tg.x buf[1] // 2)) mouse_tg.y max(0, min(display.height - 1, mouse_tg.y buf[2] // 2)) except usb.core.USBTimeoutError: pass # 没有数据是正常的继续循环这里有几个细节超时设置timeout20毫秒是一个经验值。设得太短可能漏读数据设得太长会拖慢主循环。20ms在保证响应性和CPU占用之间取得了平衡。位移处理buf[1]和buf[2]是相对位移单位由鼠标决定。我除以2// 2是为了降低光标移动速度让它更易控制。你可以根据手感调整这个除数。边界限制max(0, min(display.width - 1, ...))这行代码确保光标不会移出屏幕范围。display.width和display.height是我们在settings.toml里设置的逻辑分辨率320x240。鼠标光标本身也是一个TileGrid显示一个自定义的指针图片。为了让光标更美观代码中将精灵图中索引为0的颜色通常是背景色设置为透明mouse_bmp.pixel_shader.make_transparent(0)。这样光标就不会是一个方形的色块了。5.3 点击检测与坐标转换检测鼠标点击就是检查buf[0]的第一个比特位buf[0] (1 0) ! 0。但检测到点击后如何知道点中了哪个卡牌或按钮呢这里涉及到坐标系的转换。鼠标光标的坐标(mouse_tg.x, mouse_tg.y)是相对于整个屏幕的。而卡牌的TileGrid是放在card_grid这个GridLayout容器里的它的位置是相对于容器原点的。因此在判断点击时需要将鼠标坐标转换为相对于card_grid的坐标coords (mouse_tg.x - card_grid.x, mouse_tg.y - card_grid.y, 0) if card.contains(coords): # 点击了这张牌TileGrid.contains()方法会检查给定的坐标点是否在该TileGrid的边界矩形内。第三个参数0是z坐标在2D游戏中通常设为0。对于游戏结束画面的按钮因为它们直接放在根组main_group里位置是相对于屏幕的所以直接用鼠标的绝对坐标判断即可coords (mouse_tg.x, mouse_tg.y, 0) if play_again_btn.contains(coords): # 点击了“再玩一次”按钮6. 游戏逻辑实现详解图形和输入是骨架游戏逻辑才是灵魂。记忆游戏的逻辑可以拆解为几个核心部分卡牌初始化与布局、玩家回合管理、匹配判定与得分、游戏结束判断。6.1 卡牌池构建与随机发牌一个6x4的网格需要24张牌也就是12对。但我们只有8种不同的图案。解决方案是先确保8种图案各有一对16张然后随机选择4种图案每种再加一对8张凑齐24张。# 8种不同的卡牌正面图案索引 CARD_FRONT_SPRITE_INDEXES {1, 2, 3, 4, 5, 6, 7, 9} # 初始牌池每种图案2张 pool list(CARD_FRONT_SPRITE_INDEXES) * 2 # 随机选择4种图案作为“额外对” duplicates random_selection(CARD_FRONT_SPRITE_INDEXES, 4) # 将这4种图案各加2张到牌池 pool duplicates * 2random_selection是一个自定义函数它从集合中随机抽取不重复的指定数量元素。这样构建的pool列表就有24个元素每种图案的数量是2或4被选为“额外对”的图案有4张即两对。接下来在创建每个卡牌TileGrid的循环中我们从pool里随机抽一张牌放到card_locations列表的对应位置random_choice random.randrange(0, len(pool) - 1) if len(pool) 1 else 0 card_locations.append(pool.pop(random_choice))card_locations列表非常重要。它的索引i对应屏幕上第i个卡牌位置按行优先顺序i y * 6 x它的值card_locations[i]就是该位置卡牌正面图案在精灵图中的索引。当玩家点击第i张牌时我们通过card_tgs[i][0] card_locations[i]来将其翻到正面。为什么用poppool.pop(random_choice)不仅随机选择了一张牌还把它从pool中移除了。这保证了每张牌只被分配一次实现了“随机发牌且不重复”的效果。这是一种简洁高效的做法。6.2 玩家回合与状态管理游戏支持两个玩家轮流进行。用一个整数current_turn_index0或1来记录当前是谁的回合。切换回合很简单current_turn_index (current_turn_index 1) % 2。取模运算确保了它在0和1之间循环。与玩家相关的数据都用列表存储通过current_turn_index来索引player_scores [0, 0]玩家得分。colors [0xFF00FF, 0x00FF00]玩家颜色用于光标和标签。score_lbls得分标签列表。当回合切换时除了更新current_turn_index还需要更新UI以反映当前玩家current_player_lbl.color colors[current_turn_index] mouse_bmp.pixel_shader[2] colors[current_turn_index]这里直接修改了鼠标光标位图的调色板中索引为2的颜色。这是一种动态改变颜色的高效技巧无需重新加载位图。6.3 匹配判定与延迟反馈游戏的核心循环在STATE_PLAYING状态下。玩家点击一张牌如果它是背面朝上的card[0] 10就将其翻到正面card[0] card_locations[...]并把它的索引加入cards_flipped_this_turn列表。当cards_flipped_this_turn的长度达到2时表示本轮两张牌都已翻开。此时不是立即判断而是启动一个延迟if len(cards_flipped_this_turn) 2: WAIT_UNTIL ticks_ms() 1500 # 等待1500毫秒1.5秒 waiting_to_reset Trueticks_ms()返回从开机至今的毫秒数。WAIT_UNTIL记录了一个未来的时间点。在主循环中我们会检查if waiting_to_reset and now WAIT_UNTIL:当条件满足时才执行真正的匹配判断。这个1.5秒的延迟至关重要。它给了玩家观察和记忆两张牌的时间是游戏体验的一部分。如果没有这个延迟牌会瞬间翻转回去游戏将变得毫无策略性。匹配判断的逻辑很直接比较两张翻开的牌对应的精灵图索引是否相同。if card_tgs[cards_flipped_this_turn[0]][0] card_tgs[cards_flipped_this_turn[1]][0]: # 匹配成功 player_scores[current_turn_index] 1 # 将牌设置为空白图案索引8表示移除 card_tgs[cards_flipped_this_turn[0]][0] 8 card_tgs[cards_flipped_this_turn[1]][0] 8 else: # 匹配失败 # 将牌翻回背面索引10 card_tgs[cards_flipped_this_turn[0]][0] 10 card_tgs[cards_flipped_this_turn[1]][0] 10 # 切换玩家 current_turn_index (current_turn_index 1) % 2注意匹配成功后牌被设置为索引8空白图案而不是直接从屏幕上移除。这样做比动态地从Group中移除TileGrid对象更简单性能也更好。空白图案可以设计成和背景色一致达到“消失”的效果。6.4 游戏结束判定与状态切换游戏结束的条件是所有牌都对都被找到。由于每找到一对双方总得分增加1分所以当总得分等于牌的对数时游戏结束。total_pairs (grid_size[0] * grid_size[1]) // 2 # 24 / 2 12 if player_scores[0] player_scores[1] total_pairs: # 游戏结束在STATE_GAMEOVER状态下屏幕中央会显示一个对话框用TextBox和按钮TileGrid组合而成显示“玩家X胜利”或“平局”的消息并提供“再玩一次”和“退出”按钮。“再玩一次”的实现比较取巧它调用supervisor.set_next_code_file(__file__)将下次运行的文件设置为自身然后执行supervisor.reload()。这相当于软重启游戏状态全部重置回到了最开始的样子。这是一种快速实现游戏重置的方法比手动重置所有变量要可靠。7. 性能优化与调试技巧在资源有限的嵌入式设备上性能是需要时刻关注的问题。以下是几个在开发过程中总结出来的优化点和调试方法。7.1 内存与渲染优化共享资源最大的优化就是所有卡牌TileGrid共享同一个OnDiskBitmap精灵图和PixelShader调色板。这避免了24份位图数据在内存中的拷贝。避免频繁创建/销毁对象所有游戏对象卡牌、标签、按钮都在初始化时创建好游戏过程中只是修改它们的属性如hidden、color、[0]索引。对象创建和销毁在MicroPython中是比较耗时的操作。使用.mpy预编译库将.py的库文件预编译成.mpy格式可以加快导入速度并节省一些RAM。Adafruit的库包通常都提供.mpy文件。精简主循环主循环里只做必要的事。读取鼠标用了短超时避免阻塞。图形更新由displayio自动处理我们不需要手动刷新屏幕。7.2 常见问题与排查屏幕不亮或显示错乱首先检查settings.toml确保CIRCUITPY_DISPLAY_WIDTH和CIRCUITPY_DISPLAY_HEIGHT设置正确。检查HSTX连接线是否插紧显示器是否已开机并切换到正确的输入源。在代码开头添加import supervisor; print(supervisor.runtime.display)并通过串口监视器查看输出确认display对象是否成功创建。鼠标无反应确认鼠标插在USB Host口而不是编程用的USB-C口。通过串口打印usb.core.find(find_allTrue)找到的设备列表看看鼠标是否被识别。常见的鼠标VID/PID如Logitech (046d:c092)应该会出现。检查是否在Linux/Mac上遇到了内核驱动冲突问题确保代码中detach_kernel_driver的逻辑被执行。游戏运行卡顿检查主循环中是否有耗时操作比如复杂的计算或文件读写。使用ticks_ms()来测量不同代码段的执行时间找出瓶颈。确保没有在循环内创建新的Label或TileGrid对象。卡牌点击检测不准打印出鼠标坐标(mouse_tg.x, mouse_tg.y)和转换后的坐标coords确认坐标转换计算正确。检查GridLayout的x, y, width, height是否设置合理确保卡牌网格的边界框是你期望的位置。7.3 扩展与自定义建议这个项目是一个很好的起点你可以基于它进行很多扩展增加难度修改grid_size为(8, 6)或更大增加卡牌数量。同时需要调整GridLayout的尺寸和卡牌精灵图的大小可能需要更大的精灵图或更小的卡牌。更改主题替换memory_game_sprites.bmp文件使用你自己的图片。注意保持图片尺寸图块大小和索引顺序一致。索引0-7和9是卡牌正面8是空白10是卡牌背面。添加音效如果你的板子有音频输出可以在翻牌、匹配成功、游戏结束时通过audiocore和audioio播放简单的音效或提示音。支持键盘/手柄除了鼠标可以尝试集成adafruit_hid来支持USB键盘或游戏手柄控制用方向键移动焦点空格键或A键进行选择。加入动画目前的翻牌是瞬间切换。你可以尝试实现一个旋转或渐变的动画效果。这需要在一个短暂的时间内连续修改TileGrid的rotation或scale属性或者快速切换几个中间帧的精灵图索引。这个项目的代码结构清晰模块化程度高非常适合作为学习CircuitPython图形游戏开发的模板。理解了状态机如何管理流程、TileGrid如何高效渲染、以及如何整合USB输入后你就能举一反三创造出更多有趣的嵌入式交互应用了。
基于CircuitPython的嵌入式记忆游戏开发:状态机与TileGrid实战
发布时间:2026/5/15 23:03:45
1. 项目概述一个嵌入式平台上的经典记忆配对游戏如果你玩过那种翻牌配对的记忆游戏现在我们可以把它搬到一块小小的嵌入式开发板上用CircuitPython来实现。这不仅仅是把游戏逻辑移植过来那么简单它涉及到在资源受限的微控制器上如何高效地管理图形界面、处理用户输入以及组织复杂的游戏状态流转。我这次做的就是基于Adafruit Metro RP2350这类支持CircuitPython的开发板配合一块通过HSTX接口驱动的显示屏和一个USB鼠标完整复现了这个经典游戏。这个项目的核心价值在于它提供了一个非常典型的嵌入式交互应用范例。你不再只是点个灯、读个传感器数据而是构建了一个完整的、带图形界面和实时交互的应用。其中状态机是控制游戏流程的大脑它清晰地定义了“标题画面”、“游戏进行中”和“游戏结束”这三个阶段让代码逻辑变得条理分明。TileGrid则是渲染游戏画面的高效工具它允许我们使用一张包含所有卡牌图案的“精灵图”通过索引来快速切换显示内容极大地节省了内存和CPU资源。而GridLayout则负责自动排列这24张卡牌让我们从繁琐的坐标计算中解放出来。整个开发过程就是如何将这些独立的模块——显示驱动、输入处理、图形管理、游戏逻辑——优雅地整合在一起。下面我就带你一步步拆解这个项目的实现细节从硬件连接到代码的每一行分享其中踩过的坑和总结出的技巧。2. 硬件与开发环境搭建在动手写代码之前得先把舞台搭好。这个项目的硬件需求很明确一块能跑CircuitPython 8.x及以上版本、并且带USB Host功能的主控板如Metro RP2350一个用于显示的屏幕通过HSTX转DVI/HDMI以及一个最普通的USB鼠标。2.1 核心硬件选型与连接主控板我选用的是Adafruit Metro RP2350原因很简单它的RP2350芯片性能足够强劲自带USB Host端口并且有现成的HSTX接口用于驱动高清显示。当然理论上任何支持displayio、usb.core库且具备USB Host能力的CircuitPython板子都能跑但RP2350的社区支持和文档是最全的。注意务必确认你的开发板固件版本支持usb.core库。早期的CircuitPython版本可能没有完整的USB主机功能支持。刷写最新版CircuitPython固件通常是第一步。显示屏方面你需要一块支持DVI或HDMI输入的显示器或电视。连接线是标准的HDMI线但中间需要一个“HSTX to HDMI”转接板。这个转接板的作用是将主控板输出的数字视频信号转换成显示器能识别的标准HDMI信号。连接时注意方向HSTX接口有防呆设计一般不会插反。USB鼠标就随便了只要是标准HID协议的鼠标都行有线无线均可。我测试过几个不同品牌的包括一些老式的滚球鼠标兼容性都没问题。关键在于鼠标必须插在板子标记为“USB Host”的那个端口上而不是用来编程的USB-C口。2.2 软件环境与关键配置代码编辑可以用任何你喜欢的工具比如Mu Editor、VS Code with CircuitPython插件甚至简单的文本编辑器都行。因为最终我们是要把code.py和依赖库直接放到板子的CIRCUITPY磁盘里。有几个关键的配置项必须在settings.toml文件中设置这个文件位于CIRCUITPY盘的根目录。如果不存在就自己创建一个。# CIRCUITPY 根目录下的 settings.toml 文件内容 CIRCUITPY_DISPLAY_WIDTH 320 CIRCUITPY_DISPLAY_HEIGHT 240 CIRCUITPY_DISPLAY_BUS None第一行CIRCUITPY_DISPLAY_WIDTH 320至关重要。我们的游戏逻辑和素材都是基于320x240的分辨率设计的。虽然HSTX输出可能会被硬件倍频到640x480但displayio库依然需要知道我们逻辑上的帧缓冲区大小。如果不设置这一项supervisor.runtime.display可能无法正确初始化或者获取到错误的屏幕尺寸导致所有界面元素位置错乱。CIRCUITPY_DISPLAY_BUS None这一行是告诉系统我们不是通过SPI或I2C这类总线来驱动屏幕而是使用像HSTX这样的“内置”显示接口。对于RP2350这通常是必要的。2.3 依赖库管理与文件结构游戏用到了几个非核心库需要手动放入CIRCUITPY/lib目录。你可以通过Adafruit的CircuitPython库包Bundle来获取它们。具体需要以下库文件adafruit_displayio_layout/adafruit_display_text/adafruit_ticks.mpyadafruit_usb_host_descriptors.mpyadafruit_pathlib.mpy把整个adafruit_displayio_layout和adafruit_display_text文件夹复制到lib下其他.mpy文件直接放在lib根目录。最终你的CIRCUITPY磁盘文件结构应该大致如下CIRCUITPY/ ├── settings.toml ├── code.py ├── lib/ │ ├── adafruit_displayio_layout/ │ ├── adafruit_display_text/ │ ├── adafruit_ticks.mpy │ ├── adafruit_usb_host_descriptors.mpy │ └── adafruit_pathlib.mpy ├── memory_game_sprites.bmp ├── memory_title.bmp ├── btn_play_again.bmp ├── btn_exit.bmp └── mouse_cursor.bmp图片资源文件.bmp需要和code.py放在同一级目录。CircuitPython的OnDiskBitmap默认从当前工作目录加载图片。确保这些图片是索引色位图并且颜色深度与代码中使用的调色板匹配。我通常用GIMP或Aseprite来制作保存时选择“索引色”模式颜色数控制在256色以内以节省内存。3. 游戏核心架构与状态机设计写嵌入式游戏和写PC游戏的一个很大不同是你得非常小心地管理有限的内存和CPU周期。你不能开一堆线程也不能指望有强大的垃圾回收。状态机State Machine在这里就成了管理复杂流程的利器。它的思想很简单游戏在任何时刻都处于一个明确的、有限的状态之一并且根据特定事件比如鼠标点击在这些状态间切换。3.1 状态定义与流转逻辑在这个记忆游戏中我定义了三个核心状态用简单的整数常量表示STATE_TITLE 0 # 标题画面等待开始 STATE_PLAYING 1 # 游戏进行中 STATE_GAMEOVER 2 # 游戏结束显示结果 CUR_STATE STATE_TITLE # 当前状态初始为标题画面这个设计看似简单却极大地简化了主循环的逻辑。在主循环的每一次迭代中我们只需要检查CUR_STATE的值然后执行对应状态下的代码块。比如只有在STATE_PLAYING状态下我们才需要处理卡牌的点击和匹配判断在STATE_GAMEOVER状态下则只关心“再玩一次”或“退出”按钮的点击。状态转换的触发条件也很清晰从STATE_TITLE到STATE_PLAYING在标题画面任意位置点击鼠标左键。从STATE_PLAYING到STATE_GAMEOVER当所有卡牌配对成功即玩家总得分等于卡牌对数时。从STATE_GAMEOVER到STATE_TITLE点击“再玩一次”按钮实际是通过软重启supervisor.reload()重新加载代码回到初始状态。从STATE_GAMEOVER退出点击“退出”按钮同样是supervisor.reload()但这里假设回到默认的code.py你可以设计更复杂的逻辑。实操心得使用整数常量而不是字符串来定义状态效率更高因为整数比较比字符串比较快得多。用CUR_STATE这样的全局变量来跟踪状态虽然简单直接但在更复杂的项目中可以考虑将状态和状态转换逻辑封装成一个类这样可读性和可维护性会更好。3.2 主循环与事件分发有了状态机主循环就变得非常整洁。它只做四件事1) 读取输入鼠标2) 更新游戏逻辑根据当前状态3) 更新显示4) 等待下一帧如果需要控制帧率。在我们的游戏里因为事件驱动性很强主要响应点击所以没有用固定的帧率循环而是用一个while True循环不断检查。while True: # 1. 读取鼠标数据 try: data_len mouse.read(mouse_endpoint_address, buf, timeout20) # ... 更新鼠标光标位置 except usb.core.USBTimeoutError: pass # 没读到数据是正常的 # 2. 根据当前状态执行不同逻辑 if CUR_STATE STATE_TITLE: # 处理标题画面点击 if 鼠标左键按下: CUR_STATE STATE_PLAYING # ... 隐藏标题画面等操作 elif CUR_STATE STATE_PLAYING: # 处理游戏逻辑详见后文 pass elif CUR_STATE STATE_GAMEOVER: # 处理结束画面按钮点击 pass这种结构的好处是隔离性好。PLAYING状态下的复杂逻辑不会干扰到TITLE状态的简单展示。调试的时候也方便你可以很容易地知道问题出在哪个状态模块里。4. 图形系统TileGrid与GridLayout的深度应用在嵌入式设备上做图形尤其是动画直接操作像素是非常低效的。CircuitPython的displayio库提供了一套基于“图块”Tile的渲染系统TileGrid和GridLayout是其中的核心。4.1 精灵图Sprite Sheet与TileGrid原理所有卡牌的图案包括正面的8种图案、背面和空白都被做进了一张名为memory_game_sprites.bmp的位图里。这张图就是“精灵图”。想象一下老式的街机游戏或者早期RPG所有角色动作都放在一张大图上通过显示不同部分来形成动画原理是一样的。TileGrid对象的作用就是在这张大图上开一个“窗口”只显示其中一部分。我们为游戏中的24张卡牌创建了24个独立的TileGrid对象但它们都共享同一张精灵图位图数据。这比为每张卡牌都加载一个单独的图片文件要节省得多内存。创建TileGrid的关键参数new_tg TileGrid( bitmapsprites, # 共享的精灵图对象 default_tile10, # 默认显示的图块索引卡牌背面 tile_height32, # 每个图块的高度像素 tile_width32, # 每个图块的宽度像素 height1, width1, # 这个TileGrid由1x1个图块组成即只显示一个图案 pixel_shadersprites.pixel_shader, # 共享的调色板 )default_tile10意味着初始时这个TileGrid显示精灵图中索引为10的图块也就是卡牌背面。当玩家点击这张牌时我们只需要一句card_tg[0] target_index就能瞬间将显示切换到目标索引的图案卡牌正面实现“翻转”的视觉效果。这个操作只改变了一个索引值重绘由displayio底层自动完成效率极高。4.2 自动布局神器GridLayout手动计算24张卡牌在320x240屏幕上的位置x, y坐标是个噩梦而且难以维护。GridLayout就是为了解决这个问题而生的。你只需要告诉它容器的尺寸、网格的行列数然后把子元素我们的TileGrid加进去它就会自动帮你排列整齐。# 创建一个网格布局位于(10,10)宽260像素高200像素网格为6列4行 card_grid GridLayout(x10, y10, width260, height200, grid_size(6, 4))创建好布局容器后在嵌套循环中创建每个卡牌的TileGrid并通过add_content方法将其添加到网格的特定位置card_grid.add_content(new_tg, grid_position(x, y), cell_size(1, 1))这里的grid_position(x, y)指定了这张牌位于网格的第x列、第y行从0开始计数。cell_size(1, 1)表示这个元素占据1x1个网格单元格。GridLayout会根据容器总宽高和网格数自动计算出每个单元格的尺寸并将元素居中放置在其中。踩坑记录最初我尝试自己计算每个卡牌的位置代码里充满了x margin col * (card_width spacing)这样的公式。一旦想调整边距或间距就得改好几个地方。换成GridLayout后只需要调整容器的x, y, width, height所有卡牌会自动重新排列。布局的代码量减少了70%而且更清晰。强烈建议在需要规则排列UI元素时使用它。4.3 其他UI元素标签与按钮除了卡牌游戏界面还有得分标签、当前玩家指示器和游戏结束对话框。得分标签用的是adafruit_display_text.bitmap_label.Label它比普通的label渲染更快。关键点在于使用anchor_point和anchored_position进行定位。score_lbl.anchor_point (0, 0) # 将标签的左上角作为锚点 score_lbl.anchored_position (4, 1) # 将锚点放置在屏幕坐标(4, 1)的位置anchor_point是一个归一化坐标。(0,0)代表左上角(1.0, 1.0)代表右下角(0.5, 0.5)代表中心。通过组合锚点和定位可以轻松实现“左上角对齐”、“居中”、“右下角对齐”等常见布局而无需手动计算文本宽度。游戏结束时的“再玩一次”和“退出”按钮本质上也是TileGrid只不过它们的位图是按钮图片。检测点击的方法和检测卡牌点击一样都是使用TileGrid.contains(coords)方法来判断鼠标坐标是否在按钮的边界框内。5. 输入处理USB鼠标的读取与光标控制在嵌入式设备上使用USB鼠标听起来有点大材小用但对于需要精确点选的应用来说它比按键方便太多了。CircuitPython通过usb.core库提供了底层USB主机功能。5.1 鼠标设备的发现与初始化代码一开始会扫描所有连接的USB设备寻找符合“Boot Protocol Mouse”标准的设备。这是USB HID设备的一个标准子类绝大多数鼠标都兼容它。import usb.core import adafruit_usb_host_descriptors mouse None for device in usb.core.find(find_allTrue): mouse_interface_index, mouse_endpoint_address ( adafruit_usb_host_descriptors.find_boot_mouse_endpoint(device)) if mouse_interface_index is not None: mouse device breakfind_boot_mouse_endpoint这个辅助函数帮我们完成了繁琐的描述符解析工作直接返回鼠标数据所在的接口和端点地址。找到设备后我们需要“声明”这个设备的使用权特别是在Linux/Mac系统上需要先让内核驱动解除绑定。if mouse.is_kernel_driver_active(0): mouse_was_attached True mouse.detach_kernel_driver(0) mouse.set_configuration()atexit.register(atexit_callback)这行代码注册了一个退出回调函数。当程序结束比如板子复位时这个函数会被调用尝试将鼠标设备重新绑定回内核驱动。这是一个好习惯避免了程序崩溃后鼠标失控的情况。5.2 数据读取与光标移动Boot鼠标的报告描述符Report Descriptor通常是4个字节[按钮状态, X位移, Y位移, 滚轮]。我们创建一个4字节的数组作为缓冲区然后在一个短超时内尝试读取数据。buf array.array(b, [0] * 4) # 4字节缓冲区 try: data_len mouse.read(mouse_endpoint_address, buf, timeout20) if data_len 0: # buf[0]: 按钮位图 (bit0左键, bit1右键, bit2中键) # buf[1]: X轴相对位移 # buf[2]: Y轴相对位移 mouse_tg.x max(0, min(display.width - 1, mouse_tg.x buf[1] // 2)) mouse_tg.y max(0, min(display.height - 1, mouse_tg.y buf[2] // 2)) except usb.core.USBTimeoutError: pass # 没有数据是正常的继续循环这里有几个细节超时设置timeout20毫秒是一个经验值。设得太短可能漏读数据设得太长会拖慢主循环。20ms在保证响应性和CPU占用之间取得了平衡。位移处理buf[1]和buf[2]是相对位移单位由鼠标决定。我除以2// 2是为了降低光标移动速度让它更易控制。你可以根据手感调整这个除数。边界限制max(0, min(display.width - 1, ...))这行代码确保光标不会移出屏幕范围。display.width和display.height是我们在settings.toml里设置的逻辑分辨率320x240。鼠标光标本身也是一个TileGrid显示一个自定义的指针图片。为了让光标更美观代码中将精灵图中索引为0的颜色通常是背景色设置为透明mouse_bmp.pixel_shader.make_transparent(0)。这样光标就不会是一个方形的色块了。5.3 点击检测与坐标转换检测鼠标点击就是检查buf[0]的第一个比特位buf[0] (1 0) ! 0。但检测到点击后如何知道点中了哪个卡牌或按钮呢这里涉及到坐标系的转换。鼠标光标的坐标(mouse_tg.x, mouse_tg.y)是相对于整个屏幕的。而卡牌的TileGrid是放在card_grid这个GridLayout容器里的它的位置是相对于容器原点的。因此在判断点击时需要将鼠标坐标转换为相对于card_grid的坐标coords (mouse_tg.x - card_grid.x, mouse_tg.y - card_grid.y, 0) if card.contains(coords): # 点击了这张牌TileGrid.contains()方法会检查给定的坐标点是否在该TileGrid的边界矩形内。第三个参数0是z坐标在2D游戏中通常设为0。对于游戏结束画面的按钮因为它们直接放在根组main_group里位置是相对于屏幕的所以直接用鼠标的绝对坐标判断即可coords (mouse_tg.x, mouse_tg.y, 0) if play_again_btn.contains(coords): # 点击了“再玩一次”按钮6. 游戏逻辑实现详解图形和输入是骨架游戏逻辑才是灵魂。记忆游戏的逻辑可以拆解为几个核心部分卡牌初始化与布局、玩家回合管理、匹配判定与得分、游戏结束判断。6.1 卡牌池构建与随机发牌一个6x4的网格需要24张牌也就是12对。但我们只有8种不同的图案。解决方案是先确保8种图案各有一对16张然后随机选择4种图案每种再加一对8张凑齐24张。# 8种不同的卡牌正面图案索引 CARD_FRONT_SPRITE_INDEXES {1, 2, 3, 4, 5, 6, 7, 9} # 初始牌池每种图案2张 pool list(CARD_FRONT_SPRITE_INDEXES) * 2 # 随机选择4种图案作为“额外对” duplicates random_selection(CARD_FRONT_SPRITE_INDEXES, 4) # 将这4种图案各加2张到牌池 pool duplicates * 2random_selection是一个自定义函数它从集合中随机抽取不重复的指定数量元素。这样构建的pool列表就有24个元素每种图案的数量是2或4被选为“额外对”的图案有4张即两对。接下来在创建每个卡牌TileGrid的循环中我们从pool里随机抽一张牌放到card_locations列表的对应位置random_choice random.randrange(0, len(pool) - 1) if len(pool) 1 else 0 card_locations.append(pool.pop(random_choice))card_locations列表非常重要。它的索引i对应屏幕上第i个卡牌位置按行优先顺序i y * 6 x它的值card_locations[i]就是该位置卡牌正面图案在精灵图中的索引。当玩家点击第i张牌时我们通过card_tgs[i][0] card_locations[i]来将其翻到正面。为什么用poppool.pop(random_choice)不仅随机选择了一张牌还把它从pool中移除了。这保证了每张牌只被分配一次实现了“随机发牌且不重复”的效果。这是一种简洁高效的做法。6.2 玩家回合与状态管理游戏支持两个玩家轮流进行。用一个整数current_turn_index0或1来记录当前是谁的回合。切换回合很简单current_turn_index (current_turn_index 1) % 2。取模运算确保了它在0和1之间循环。与玩家相关的数据都用列表存储通过current_turn_index来索引player_scores [0, 0]玩家得分。colors [0xFF00FF, 0x00FF00]玩家颜色用于光标和标签。score_lbls得分标签列表。当回合切换时除了更新current_turn_index还需要更新UI以反映当前玩家current_player_lbl.color colors[current_turn_index] mouse_bmp.pixel_shader[2] colors[current_turn_index]这里直接修改了鼠标光标位图的调色板中索引为2的颜色。这是一种动态改变颜色的高效技巧无需重新加载位图。6.3 匹配判定与延迟反馈游戏的核心循环在STATE_PLAYING状态下。玩家点击一张牌如果它是背面朝上的card[0] 10就将其翻到正面card[0] card_locations[...]并把它的索引加入cards_flipped_this_turn列表。当cards_flipped_this_turn的长度达到2时表示本轮两张牌都已翻开。此时不是立即判断而是启动一个延迟if len(cards_flipped_this_turn) 2: WAIT_UNTIL ticks_ms() 1500 # 等待1500毫秒1.5秒 waiting_to_reset Trueticks_ms()返回从开机至今的毫秒数。WAIT_UNTIL记录了一个未来的时间点。在主循环中我们会检查if waiting_to_reset and now WAIT_UNTIL:当条件满足时才执行真正的匹配判断。这个1.5秒的延迟至关重要。它给了玩家观察和记忆两张牌的时间是游戏体验的一部分。如果没有这个延迟牌会瞬间翻转回去游戏将变得毫无策略性。匹配判断的逻辑很直接比较两张翻开的牌对应的精灵图索引是否相同。if card_tgs[cards_flipped_this_turn[0]][0] card_tgs[cards_flipped_this_turn[1]][0]: # 匹配成功 player_scores[current_turn_index] 1 # 将牌设置为空白图案索引8表示移除 card_tgs[cards_flipped_this_turn[0]][0] 8 card_tgs[cards_flipped_this_turn[1]][0] 8 else: # 匹配失败 # 将牌翻回背面索引10 card_tgs[cards_flipped_this_turn[0]][0] 10 card_tgs[cards_flipped_this_turn[1]][0] 10 # 切换玩家 current_turn_index (current_turn_index 1) % 2注意匹配成功后牌被设置为索引8空白图案而不是直接从屏幕上移除。这样做比动态地从Group中移除TileGrid对象更简单性能也更好。空白图案可以设计成和背景色一致达到“消失”的效果。6.4 游戏结束判定与状态切换游戏结束的条件是所有牌都对都被找到。由于每找到一对双方总得分增加1分所以当总得分等于牌的对数时游戏结束。total_pairs (grid_size[0] * grid_size[1]) // 2 # 24 / 2 12 if player_scores[0] player_scores[1] total_pairs: # 游戏结束在STATE_GAMEOVER状态下屏幕中央会显示一个对话框用TextBox和按钮TileGrid组合而成显示“玩家X胜利”或“平局”的消息并提供“再玩一次”和“退出”按钮。“再玩一次”的实现比较取巧它调用supervisor.set_next_code_file(__file__)将下次运行的文件设置为自身然后执行supervisor.reload()。这相当于软重启游戏状态全部重置回到了最开始的样子。这是一种快速实现游戏重置的方法比手动重置所有变量要可靠。7. 性能优化与调试技巧在资源有限的嵌入式设备上性能是需要时刻关注的问题。以下是几个在开发过程中总结出来的优化点和调试方法。7.1 内存与渲染优化共享资源最大的优化就是所有卡牌TileGrid共享同一个OnDiskBitmap精灵图和PixelShader调色板。这避免了24份位图数据在内存中的拷贝。避免频繁创建/销毁对象所有游戏对象卡牌、标签、按钮都在初始化时创建好游戏过程中只是修改它们的属性如hidden、color、[0]索引。对象创建和销毁在MicroPython中是比较耗时的操作。使用.mpy预编译库将.py的库文件预编译成.mpy格式可以加快导入速度并节省一些RAM。Adafruit的库包通常都提供.mpy文件。精简主循环主循环里只做必要的事。读取鼠标用了短超时避免阻塞。图形更新由displayio自动处理我们不需要手动刷新屏幕。7.2 常见问题与排查屏幕不亮或显示错乱首先检查settings.toml确保CIRCUITPY_DISPLAY_WIDTH和CIRCUITPY_DISPLAY_HEIGHT设置正确。检查HSTX连接线是否插紧显示器是否已开机并切换到正确的输入源。在代码开头添加import supervisor; print(supervisor.runtime.display)并通过串口监视器查看输出确认display对象是否成功创建。鼠标无反应确认鼠标插在USB Host口而不是编程用的USB-C口。通过串口打印usb.core.find(find_allTrue)找到的设备列表看看鼠标是否被识别。常见的鼠标VID/PID如Logitech (046d:c092)应该会出现。检查是否在Linux/Mac上遇到了内核驱动冲突问题确保代码中detach_kernel_driver的逻辑被执行。游戏运行卡顿检查主循环中是否有耗时操作比如复杂的计算或文件读写。使用ticks_ms()来测量不同代码段的执行时间找出瓶颈。确保没有在循环内创建新的Label或TileGrid对象。卡牌点击检测不准打印出鼠标坐标(mouse_tg.x, mouse_tg.y)和转换后的坐标coords确认坐标转换计算正确。检查GridLayout的x, y, width, height是否设置合理确保卡牌网格的边界框是你期望的位置。7.3 扩展与自定义建议这个项目是一个很好的起点你可以基于它进行很多扩展增加难度修改grid_size为(8, 6)或更大增加卡牌数量。同时需要调整GridLayout的尺寸和卡牌精灵图的大小可能需要更大的精灵图或更小的卡牌。更改主题替换memory_game_sprites.bmp文件使用你自己的图片。注意保持图片尺寸图块大小和索引顺序一致。索引0-7和9是卡牌正面8是空白10是卡牌背面。添加音效如果你的板子有音频输出可以在翻牌、匹配成功、游戏结束时通过audiocore和audioio播放简单的音效或提示音。支持键盘/手柄除了鼠标可以尝试集成adafruit_hid来支持USB键盘或游戏手柄控制用方向键移动焦点空格键或A键进行选择。加入动画目前的翻牌是瞬间切换。你可以尝试实现一个旋转或渐变的动画效果。这需要在一个短暂的时间内连续修改TileGrid的rotation或scale属性或者快速切换几个中间帧的精灵图索引。这个项目的代码结构清晰模块化程度高非常适合作为学习CircuitPython图形游戏开发的模板。理解了状态机如何管理流程、TileGrid如何高效渲染、以及如何整合USB输入后你就能举一反三创造出更多有趣的嵌入式交互应用了。