基于Micro:bit与状态机设计实现交互式井字棋游戏
1. 项目概述与核心思路
最近在整理一些嵌入式开发的入门案例,发现很多朋友对Micro:bit这类教育开发板的理解还停留在点灯和显示字符的层面。其实,用它来实现一个完整的、带交互的小游戏,是理解状态机、传感器融合和实时显示等核心概念的绝佳途径。这次,我们就来动手做一个运行在Micro:bit上的井字棋游戏。整个项目不需要任何额外的电子元件,仅靠板载的5x5 LED点阵和加速度计,就能实现双人对战。我会基于Tinkercad Circuits进行仿真和代码开发,这样即使你手头没有实物硬件,也能完整地跟随并理解整个流程。
这个项目的价值在于,它把一个抽象的“嵌入式系统”概念,变成了一个看得见、摸得着的游戏。你需要思考如何用有限的25个LED去清晰地表达一个3x3的棋盘,如何用加速度计这种连续模拟量传感器来映射离散的棋盘位置,以及如何管理游戏的核心状态(比如棋盘状态、当前玩家、胜负判定)。这些思考过程,对于任何嵌入式或物联网设备的交互设计都至关重要。无论你是电子爱好者、编程初学者,还是想给课堂教学增加趣味性的老师,这个案例都能提供一套完整的、可复现的实现方案。
2. 硬件平台与开发环境解析
2.1 Micro:bit硬件资源盘点
Micro:bit虽然小巧,但作为一款为教育设计的开发板,其外设资源对于实现我们这个游戏绰绰有余。我们需要重点关注以下两部分:
首先是5x5 LED点阵。这是我们的显示输出设备。需要注意的是,这25个LED是复用驱动的,并非每个LED都能独立、同时以任意亮度点亮。在编程时,我们通过plot (x, y)或show leds等函数来控制,底层由芯片进行扫描刷新。对于井字棋,我们需要从中划出一个3x3的棋盘区域。一个直观的想法是使用四个角的LED以及中心LED作为棋盘的四个角和中心,但这样会浪费边缘的LED。更合理的方案是规划一个“#”字形,利用外围的LED来勾勒棋盘格,这样视觉上更清晰。在初始方案中,我尝试用不同的亮度等级来区分玩家,但实测发现Micro:bit的LED亮度等级在环境光稍强时区分度不够理想,因此最终改用“闪烁频率”作为视觉区分的手段,效果更佳。
其次是板载加速度计。我们用它来替代方向键或摇杆,实现棋盘位置的选择。加速度计测量的是板子在三个轴上的加速度,包括重力加速度。当板子静止时,加速度计测得的实际上是重力加速度在三个轴上的分量。因此,通过检测板子的倾斜角度(即重力方向相对于板子坐标系的变化),我们就可以判断用户意图选择哪个方向。例如,向前倾斜可能对应向上移动光标,向左倾斜对应向左移动。这里的关键在于,需要将连续的加速度传感器数值,映射到离散的0-8这九个棋盘位置上,并做好防抖处理,避免光标跳动。
2.2 Tinkercad Circuits开发流程优势
对于这个项目,我强烈推荐使用Tinkercad Circuits作为开发起点,尤其是对于初学者或没有硬件在手的开发者。它有几个不可替代的优势:
第一是零成本与快速验证。你不需要购买任何硬件,打开浏览器就能开始。Tinkercad提供了Micro:bit的精确仿真模型,其LED显示、按钮响应以及传感器数据模拟都相当真实。你可以在写完代码后立刻看到仿真效果,极大地加快了调试和迭代的速度。
第二是图形化与代码的平滑过渡。Tinkercad的编程界面基于Microsoft MakeCode,支持积木块(Blocks)和JavaScript两种模式。对于初学者,可以用积木块拖拽搭建逻辑,直观易懂;对于想深入的学习者,可以随时切换到JavaScript视图查看生成的代码,甚至直接编写代码。本项目的代码将主要以积木块逻辑进行讲解,但会同步解释其对应的代码逻辑,帮助你理解底层原理。
第三是便捷的代码部署。在Tinkercad中完成仿真测试后,你可以一键将项目导出为.hex文件。这个文件就是Micro:bit的可执行固件。将Micro:bit通过USB连接到电脑,它会显示为一个名为MICROBIT的U盘,只需把.hex文件拖进去,板子会自动重启并运行新程序,过程非常简单。
注意:仿真与实物的传感器差异:在开发过程中我发现一个关键点,Tinkercad仿真环境与真实Micro:bit(以及MakeCode官方模拟器)在加速度计的Y轴数据上符号可能是相反的。这意味着,在仿真里向左倾斜光标向右移动的代码,在真实硬件上可能正好相反。因此,在仿真测试逻辑通过后,务必在真实硬件上测试传感器映射部分,并根据实际情况调整正负号。这是一个非常重要的“仿真到实物”迁移经验。
3. 游戏逻辑与状态机设计
3.1 数据结构定义:如何用变量表示棋盘
在嵌入式编程中,合理设计数据结构是高效管理内存和逻辑的前提。对于井字棋的3x3棋盘,我们首先需要一种方式来记录每个格子的状态:空、玩家1占据、玩家2占据。
一个简单的方法是使用一个长度为9的数组。但这里我采用了一个更紧凑的单整数表示法:用一个9位的十进制数state来表示棋盘,每一位(从高位到低位或低位到高位均可)对应一个棋盘位置(0-8)。例如,初始化时所有位置为空,我们可以设定空状态用数字3表示,那么初始state = 333333333。如果玩家1在位置4(中央)落子,我们就将state的第4位(假设从0开始计数)从3改为1。这种方法的优势在于,判断胜负时,我们可以通过数学运算(取模、整除)快速提取某一位的状态,而不需要遍历数组,在资源受限的嵌入式环境中是一种巧妙的优化。
除了state,我们还需要其他几个核心状态变量:
playerFlag: 当前行动玩家标志,取值1或2,每回合结束后切换。winnerFlag: 游戏状态标志。0表示游戏进行中,1或2表示对应玩家获胜,3表示平局。currentPos: 当前光标选中的棋盘位置(0-8),由加速度计控制。count: 已落子的格子总数,用于快速判断是否平局(当count为9且无人获胜时即为平局)。
3.2 核心状态流转与胜负判定
游戏的核心是一个状态机,其状态流转由玩家动作(按A键)驱动。流程图可以简单描述为:初始化 -> 等待玩家倾斜选择位置(currentPos更新) -> 玩家按A键 -> 检查currentPos在state中是否为空 -> 若空,则更新state,count加1,并立即调用胜负判定函数。
胜负判定是算法的关键。井字棋有8种获胜组合(3行、3列、2条对角线)。我们需要在每次落子后,检查新落子所在的行、列以及对角线(如果适用)是否被同一玩家占据。基于我们state变量的设计,一种高效的实现方式是:预先定义好8个获胜组合的掩码(例如,位置[0,1,2]为第一行),然后在每次落子后,将当前玩家的棋子位置模式与这些掩码进行比较。如果state中对应这三个位置的值都等于当前玩家标志(1或2),则判定该玩家获胜。
如果未获胜,则检查count是否等于9。若等于9,则判定为平局,设置winnerFlag=3。若既未获胜也未平局,则切换playerFlag,游戏继续。
实操心得:状态检查的时机:胜负判定务必在每次落子后立即进行,而不是在下一个循环中。这确保了游戏状态响应的实时性。同时,将平局判断放在胜负判断之后,因为平局的前提是棋盘已满且无人获胜,这个逻辑顺序不能错。
3.3 人机交互设计:视觉与控制反馈
良好的交互设计能极大提升用户体验。在这个项目中,我们通过LED和加速度计来实现。
视觉反馈方案:
- 棋盘网格:始终点亮,用于界定游戏区域。可以使用较低亮度或间隔点亮的模式。
- 玩家棋子:
- 玩家1:用慢速闪烁的LED表示(例如,每800ms切换一次亮灭)。闪烁能形成动态吸引,与玩家2形成强对比。
- 玩家2:用常亮的红色LED表示。常亮代表稳定占据。
- 当前选择光标:用快速闪烁的LED表示(例如,每200ms切换一次)。快闪能清晰指示当前可操作的位置,与已落子的棋子明显区分。
控制反馈方案:
- 位置选择:通过倾斜Micro:bit来控制
currentPos。需要将加速度计在X和Y轴上的读数(范围约-1024到1024)映射到0-2的二维坐标,再转换为一维的0-8索引。映射算法需要加入“死区”和“滞后”处理,防止因微小抖动导致光标频繁跳动。 - 确认落子:按下A键。按下时,程序检查
currentPos位置是否为空,若空则落子并触发状态更新与判定。 - 游戏重置:同时按下A+B键。无论游戏处于进行中、已结束还是平局状态,此操作都将所有状态变量恢复为初始值,重新开始一局。
4. 代码实现与分步详解
我们将使用MakeCode的积木块环境进行开发,所有逻辑将分布在几个并行执行的“forever循环”和事件处理函数中。这种基于事件和并行的编程模型,是Micro:bit这类交互式设备的典型开发模式。
4.1 初始化与变量声明
程序启动时,我们需要完成所有状态变量的初始化,并绘制静态的棋盘网格。
- 创建变量:在变量类别中,创建之前提到的
state、playerFlag、winnerFlag、currentPos、count等变量。 - 初始化赋值:
state设为333333333。playerFlag设为1(玩家1先手)。winnerFlag设为0(游戏进行中)。currentPos设为4(默认光标在中央)。count设为0。
- 绘制棋盘网格:使用
plot (x, y)函数,点亮构成“#”字形网格的LED。例如,坐标(0,0), (2,0), (4,0), (0,2), (2,2), (4,2), (0,4), (2,4), (4,4)可以构成一个九宫格。为了不干扰棋子显示,可以将网格LED的亮度设为较低值(如plot x y brightness 50)。
注意:
plot与show leds的选择:plot函数可以精确控制每个LED的亮灭和亮度,适合动态更新局部区域。show leds更适合显示静态图案。这里我们选择plot,因为我们需要独立控制每个“格子”LED的闪烁模式。
4.2 循环一:实时渲染玩家棋子
这个循环负责根据state变量的值,持续更新棋盘上已落棋子的显示状态。由于玩家1的棋子需要闪烁,这个循环必须持续运行。
- 使用
forever积木块开始一个无限循环。 - 在循环内,使用
for循环索引i从0到8,遍历每个棋盘位置。 - 通过数学运算从
state中提取第i位的值。方法可以是:(state / (10 ^ (8-i))) % 10(假设高位对应位置0)。将这个值存入临时变量cellState。 - 使用
if...else if逻辑判断cellState:- 如果等于1(玩家1),则调用一个控制慢速闪烁的子函数或逻辑,在对应坐标的LED上实现亮灭交替。
- 如果等于2(玩家2),则使用
plot x y brightness 255让对应LED常亮。 - 如果等于3(空),则对应LED保持熄灭(或显示为网格的一部分,取决于你的绘制逻辑)。
- 循环末尾添加一个短暂的
pause (ms),例如50ms,以控制刷新率,避免过于频繁的运算。
4.3 循环二:加速度计处理与光标显示
这个循环负责读取加速度计数据,更新currentPos,并在对应位置显示快速闪烁的光标。
- 另一个
forever循环。 - 读取加速度计
acceleration (mg)在X和Y轴上的值,分别存入变量accX和accY。 - 映射算法:将
accX和accY的原始值(约±1024)映射到0-2的区间。- 例如:
row = Math.round((accY + 1024) / 682) - 1。这里682≈2048/3。Math.round用于取整,-1是为了得到-1,0,1的结果,再通过Math.constrain限制在0-2。对accX做类似处理得到col。 - 关键调整:根据你的硬件实测,可能需要交换X/Y轴,或对某个轴的数据取反。这就是之前提到的仿真与实物差异所在。
- 例如:
- 计算一维位置:
currentPos = row * 3 + col。 - 防抖处理:比较新计算出的
currentPos与上一次的值。只有当差值不为0,且新位置在棋盘范围内(0-8),并且该位置在state中为空时,才更新currentPos变量。这能有效防止因手部微小颤动导致的光标跳跃。 - 光标显示:在最新的
currentPos对应的坐标上,实现一个快速闪烁效果(例如,每200ms切换一次亮灭)。注意,当winnerFlag不为0(游戏结束)时,应停止光标闪烁。
4.4 事件处理:按钮逻辑
Micro:bit的按钮事件是中断驱动的,效率很高。
on button A pressed事件:
- 首先检查
winnerFlag是否为0(游戏是否在进行中),如果不是则直接退出。 - 检查
currentPos位置在state中是否为空(值为3)。 - 若为空,则执行落子: a. 更新
state:这需要一些数学运算,将state中第currentPos位的数字3替换为playerFlag(1或2)。一种方法是计算出要替换位的权重因子,然后进行减法和加法操作。 b.count增加1。 c. 立即调用胜负判定子程序。该子程序遍历8种获胜组合,检查state中对应的三个位置是否都等于playerFlag。如果是,则设置winnerFlag = playerFlag。 d. 如果未获胜,则检查count是否等于9。若是,则设置winnerFlag = 3(平局)。 e. 如果winnerFlag仍为0,则切换playerFlag(1变2,2变1)。 - 若该位置不为空,则可以添加一个简单的错误提示,例如让所有LED短暂闪烁一下,提示落子无效。
on button A+B pressed事件:
- 重置所有游戏变量到初始状态:
state=333333333,playerFlag=1,winnerFlag=0,count=0,currentPos=4。 - 清除屏幕,然后重新绘制棋盘网格。
- 这个事件在任何时候按下都应生效,提供强制重启游戏的功能。
4.5 循环三:游戏结束处理与显示
第三个forever循环专门用于监控游戏结束状态,并显示结果。
- 循环内持续检查
winnerFlag。 - 如果
winnerFlag == 1或2,则在LED点阵上滚动显示"P1 WINS"或"P2 WINS"字样。 - 如果
winnerFlag == 3,则滚动显示"TIE GAME"。 - 在显示结果的同时,可以暂停或简化其他循环中的某些更新逻辑(比如棋子渲染可以继续,但光标移动应停止)。
5. 仿真、调试与部署到硬件
5.1 在Tinkercad Circuits中进行仿真
- 访问Tinkercad网站并创建新电路。
- 从组件库中添加一个“Micro:bit”。
- 切换到“代码”视图,选择“块”编程模式。
- 将前面章节所述的积木逻辑逐一搭建起来。Tinkercad的优势在于,你每搭建一部分逻辑,都可以点击“开始仿真”按钮实时看到Micro:bit模拟器上的效果。你可以用鼠标拖动模拟的Micro:bit来模拟倾斜,点击A、B按钮进行测试。
- 重点测试:
- 倾斜控制是否流畅,光标移动是否符合直觉(可能需要调整映射公式中的正负号)。
- 按A键落子后,棋子显示是否正确(玩家1慢闪,玩家2常亮)。
- 连成三子后,胜负判定是否立即触发,并显示正确的获胜信息。
- 同时按A+B键是否能正确重置游戏。
5.2 从Tinkercad导出代码到MakeCode
在Tinkercad中仿真无误后,需要将代码转移到MakeCode环境以生成最终的.hex文件。
- 在Tinkercad代码编辑器的底部,找到并点击“下载”按钮(通常是一个向下箭头图标)。这将下载一个
.hex文件,但这个文件是Tinkercad特制的,不能直接用于真实Micro:bit。 - 更可靠的方法是:在Tinkercad的块编辑界面,右上角有一个“</>”图标,点击可以切换到JavaScript视图。全选并复制所有JavaScript代码。
- 打开一个新的浏览器标签页,访问MakeCode for Micro:bit编辑器 (
https://makecode.microbit.org/#editor)。 - 在MakeCode中,确保当前是JavaScript视图(点击顶部工具栏的
{}JavaScript按钮)。 - 清空编辑器中原有的所有代码,然后粘贴从Tinkercad复制的代码。
- 点击模拟器窗口下的“重启模拟器”按钮,在MakeCode的模拟器中再次测试所有功能。这一步至关重要,因为MakeCode的模拟器行为更接近真实硬件。
- 测试通过后,点击编辑器底部的“下载”按钮,这将下载一个通用的
microbit-XXXX.hex文件。这个文件才是用于真实硬件的。
5.3 烧录程序到Micro:bit实体
- 使用Micro-USB数据线将Micro:bit连接到电脑。连接成功后,电脑上会出现一个名为
MICROBIT的U盘驱动器。 - 将上一步从MakeCode下载的
.hex文件,直接复制或拖拽到MICROBIT驱动器中。 - Micro:bit背面的黄色信号灯会快速闪烁,表示正在烧录程序。等待闪烁停止,程序即自动开始运行。
- 实物测试与校准:拿起Micro:bit进行游戏。此时最可能遇到的问题就是倾斜方向控制是反的。如果出现这种情况,不要慌,回到MakeCode代码中,找到处理加速度计映射的部分(循环二),尝试对
accX或accY的数值乘以-1(取反),然后重新下载测试,直到控制方向符合你的直觉。
5.4 (可选)3D打印外壳增强体验
为了获得更好的视觉体验,可以为Micro:bit设计一个简单的遮罩外壳。这个外壳的作用是遮挡掉非棋盘格的LED,只露出我们用来表示9个格子的那9个LED,形成一个清晰的九宫格。
- 设计要点:使用3D建模软件(如Tinkercad的3D设计功能)创建一个薄板,板上开有9个圆孔或方孔,孔的位置必须精确对应Micro:bit上那9个用作棋盘的LED。
- 固定方式:可以设计成卡扣式,直接扣在Micro:bit正面;或者设计一个底座,将Micro:bit插入其中。确保外壳不会按压到背面的复位按钮。
- 打印与安装:将设计好的模型导出为
.stl文件,用3D打印机打印。安装时,注意方向,确保9个孔洞完美对齐9个LED。 - 使用外壳后,棋盘视觉效果会立刻变得专业很多,棋子显示也更加聚焦,大大提升了游戏的沉浸感。
6. 项目总结与扩展思考
完成这个项目后,你收获的不仅仅是一个能玩的井字棋游戏,更是一套针对Micro:bit或类似嵌入式平台的通用开发方法。从状态机设计、传感器数据处理到实时图形反馈,这些模块的组合可以应用到无数其他项目中,比如一个简单的贪吃蛇游戏、一个姿态控制的指南针或者一个反应速度测试器。
回顾整个开发过程,有几点深刻的体会:第一,仿真先行能节省大量硬件调试时间,但必须清醒认识仿真的局限性,传感器行为的差异是常见的“坑”。第二,交互设计需要迭代,最初我用亮度区分玩家,效果不佳,改为闪烁频率后立竿见影。这说明在嵌入式UI设计中,动态变化往往比静态属性更能吸引注意力。第三,代码结构清晰至关重要,将不同的功能(如渲染、传感器处理、游戏逻辑)分离到不同的forever循环或事件处理器中,能让程序更易理解和维护。
这个项目还有很大的扩展空间。例如,可以加入简单的AI,让Micro:bit作为玩家2自动对弈;可以增加音效,利用Micro:bit的蜂鸣器(如果有)在落子或获胜时发出声音;还可以通过无线电功能,实现两台Micro:bit之间的无线对战。每一个扩展方向,都是对已有知识的深化和新技能的学习。希望这个详细的教程能为你打开嵌入式游戏开发的大门,享受创造交互式设备的乐趣。
