Arduino实现Simon记忆游戏:状态机编程与按键消抖实战
简介这是面向网页初学者的一款经典西蒙记忆游戏制作资源基于网页标签、样式表与脚本语言完整演示随机序列生成、事件监听、音效播放、状态流转等前端交互核心逻辑适合正在学习前端开发或想用实战小项目巩固基础的人。压缩包共三个文件其中网页结构文档负责搭建游戏面板与序列显示区域样式表负责四色按钮的圆角外观与悬停效果脚本文件承载游戏控制与胜负判断整体仅四KB轻量精简便于逐行阅读。目前已有六百一十二人学习在同类入门资源中具有一定人气。内容采用详细注释的写法从页面骨架、样式美化到脚本逻辑逐步展开在脚本部分可以看到作者用数组保存随机颜色序列通过逐项展示与用户点击比对完成挑战并加入音效播放和失误重置等细节能帮助读者直观理解异步时序与状态管理。示例代码可运行稍作扩展还能加入计分、关卡或难度选择既练手又具扩展空间。 很多人第一反应是Simon游戏不就是那个按顺序亮灯、跟着按按键的记忆玩具吗真到自己动手做一遍就会发现逻辑本身不复杂但要把“演示序列 → 等待输入 → 逐位校验 → 进入下一关”这一整套流程写清楚、写稳还是很考验状态机思维的。这篇文章记录我用 Arduino 完整实现 Simon 游戏的制作过程附详细注释适合刚学完循环、数组、函数想找一个综合项目练手的读者参考。1. 开工前先明确规则与方案选型1.1 游戏规则其实比你想的更简单Simon 游戏的核心规则一句话就能讲完主机先演示一串颜色闪烁序列玩家必须按照完全相同的顺序按键复现每通过一轮序列就增加一个随机颜色直到玩家按错为止。但“规则简单”和“程序容易写”是两码事。整个游戏里并没有复杂的算法难在状态的切换什么时候该由主机表演什么时候该听玩家输入输入到一半发现错了怎么处理一轮结束之后怎么把控制权交还给主机。如果一上来就埋头写代码很容易写成“看起来能跑但玩两轮就莫名其妙卡死”的样子。我建议先把这四条规则定死每个新回合把上一轮的完整序列保留再在末尾追加一个新的随机颜色。序列播放期间屏蔽玩家按键。玩家输入期间逐位与序列比对一旦不匹配立即进入失败流程。玩家完整输入完当前序列长度本轮成功自动进入下一轮。这四条规则对应到代码里就是一个状态机最基本的四个状态。1.2 三种常见载体硬件、网页、命令行Simon 游戏可以用很多形式实现关键看你想练什么命令行版用 1/2/3/4 数字代替颜色逻辑最纯粹但缺少直观反馈做完成就感一般。Web 版用 HTML/CSS/JavaScript 做彩色按钮和音效视觉反馈好适合练 DOM 操作和异步时序。Arduino 硬件版用四个 LED 和四个按键做一个实物能接触引脚、消抖、蜂鸣器发声综合性强演示效果也最接近当年的实体玩具。我最终选了硬件版。原因很直接Simon 游戏本质上是一个“输入输出实时交互”的程序放在 Arduino 上做你能亲眼看到状态切换带来的现象变化比盯着命令行直观得多。而且硬件版的调试过程本身能教你很多纯软件学不到的东西比如按键抖动、浮空引脚、供电不足。1.3 为什么推荐 Arduino 而不是其他开发板Arduino Uno 在这个项目里是完全够用的而且有一个优势引脚操作足够底层但又不至于让你陷入寄存器配置。四个按键接数字引脚四个 LED 接数字引脚一个无源蜂鸣器接 PWM 引脚整份代码几乎是纯逻辑不依赖任何库。如果你用的是 ESP32 或者 STM32也不是不行但那些板子的强项在联网和复杂外设上做这种简单记忆游戏反而多了一些不必要的配置成本。Arduino Uno 或者 Nano 这种“老家伙”才是这种教学项目的合适载体。2. 核心逻辑拆解序列、比对与状态流转2.1 数据模型两个问题必须先想清楚写代码之前先想清楚数据怎么存。很多新手喜欢把“当前序列”和“玩家输入历史”都存成完整数组然后每次比较两个数组这个思路没错但会多写很多没必要的代码。我的做法是只存一份序列玩家输入时只维护一个“当前输入位置”的指针int sequence[100]; // 最多存 100 步的序列 int sequenceLength; // 当前回合的序列长度 int inputIndex; // 玩家已经输到了第几位为什么这样设计每一轮开始我们把一个新的随机值追加到sequence[sequenceLength]然后让sequenceLength。玩家按键时只需要把按键对应的颜色编号跟sequence[inputIndex]对比如果一致就inputIndex如果不一致就失败。当inputIndex sequenceLength时说明玩家已经完整复现了整个序列本轮通过。这个方式避免了“复制一份玩家序列再整体比较”这种多余操作。因为游戏规则本身就要求“逐位正确”并不需要把玩家按过的所有键保存下来做离线判断。就好比跟读一串电话号码只要当前这一位读对了就继续往下读错了重来。2.2 状态流转用枚举把游戏切成几个阶段整个游戏可以拆成四个状态状态含义进入条件退出条件播放序列主机按顺序点亮 LED 并响音调开机或新回合开始序列播放完毕等待输入玩家按键播放完毕玩家按下任意有效键校验中逐位比较玩家按下键比对完成或错误失败结束闪烁提示并复位校验到错误若干秒后重新开始Arduino 代码里我用全局变量isPlayingSequence和gameOver来标记状态。其实也可以用枚举变量但为了照顾新手我用两个布尔值配合函数调用逻辑会更直白。2.3 为什么“只追加不重排”能保持难度曲线有的实现是每次随机生成一个全新的序列长度固定为当前回合数。这样也能玩但有一个明显问题玩家每次都需要从头记忆一整串新随机数并且每次的序列跟前一轮完全没有关联。只追加一个新随机项的做法本质上是在“旧记忆”的基础上增加一个“新记忆”更符合人类记忆的累积特性也让每一步的挑战增量保持稳定。代码上就一行sequence[sequenceLength] random(0, 4); sequenceLength;这个“追加随机”的设计是 Simon 游戏逻辑里最关键的一个决策它直接影响代码的简洁度也直接影响游戏的体验节奏。3. 手把手写代码带详细注释的完整实现3.1 电路连接四盏灯、四个键、一个喇叭先看硬件连接。Arduino Uno 的数字引脚布局很简单我建议这样接元件引脚说明绿色 LED8序列中的 0 号颜色红色 LED9序列中的 1 号颜色黄色 LED10序列中的 2 号颜色蓝色 LED11序列中的 3 号颜色绿色按键2对应绿色红色按键3对应红色黄色按键4对应黄色蓝色按键5对应蓝色无源蜂鸣器6声音反馈四个按键一端接数字引脚另一端接地。如果希望代码简单可以不开内部上拉而是把按键另一端接 GND按键按下时引脚读到 LOW这样代码里判断digitalRead(btnPin) LOW就是“按下”。LED 记得串联一个 220Ω 电阻直接接到引脚虽然也能亮但长期使用对引脚电流不友好。3.2 完整代码每个关键点都写了注释下面这份代码我在 Arduino Uno 上实测通过所有注释都放在核心逻辑旁。建议你复制到 Arduino IDE 里对照注释一行一行读。// // Simon 游戏制作详细注释版 // 硬件平台Arduino Uno // 功能经典记忆游戏4 个彩色 LED 4 个按键 蜂鸣器 // 作者个人项目 // // 引脚定义 const int ledPins[4] {8, 9, 10, 11}; // 四个 LED 引脚 const int buttonPins[4] {2, 3, 4, 5}; // 四个按键引脚 const int buzzerPin 6; // 蜂鸣器引脚 // 游戏数据 int sequence[100]; // 存放整条序列最多支持 100 步 int sequenceLength 0; // 当前回合的序列长度 int inputIndex 0; // 玩家已经输入到的位置 bool isPlayingSequence true; // true 表示当前正在播放序列 bool gameOver false; // true 表示游戏失败 // 每个颜色对应的音调频率 int noteFreq[4] {262, 330, 392, 523}; // C4、E4、G4、C5 void setup() { // 随机种子如果不播种每次上电生成的序列都一样 randomSeed(analogRead(A0)); // 初始化引脚 for (int i 0; i 4; i) { pinMode(ledPins[i], OUTPUT); pinMode(buttonPins[i], INPUT_PULLUP); } pinMode(buzzerPin, OUTPUT); // 启动画面四个灯依次闪一遍提示玩家游戏开始 for (int i 0; i 4; i) { digitalWrite(ledPins[i], HIGH); tone(buzzerPin, noteFreq[i]); delay(200); noTone(buzzerPin); digitalWrite(ledPins[i], LOW); } delay(500); // 游戏正式开始先出第一回合的序列长度为 1 startNewRound(); } void loop() { // 游戏失败后动画和逻辑都停在这里处理 if (gameOver) { handleGameOver(); return; } // 如果当前正在播放序列播放完就切换到玩家输入阶段 if (isPlayingSequence) { playSequence(); isPlayingSequence false; inputIndex 0; // 玩家从头开始输入 } // 读取玩家按键 int pressed readButtonPress(); if (pressed ! -1) { // 按下一个键先让对应 LED 亮一下作为反馈 flashLed(pressed); // 校验按下的键必须和序列中当前位一致 if (pressed sequence[inputIndex]) { inputIndex; // 如果已经输完整个序列说明本轮通过 if (inputIndex sequenceLength) { delay(300); startNewRound(); // 追加一个新颜色重新播放序列 } } else { // 按错进入失败流程 gameOver true; } } } // 开始新的一轮在序列末尾追加一个随机颜色 void startNewRound() { sequence[sequenceLength] random(0, 4); sequenceLength; isPlayingSequence true; } // 播放整条序列每个 LED 亮 400 毫秒中间间隔 150 毫秒 void playSequence() { for (int i 0; i sequenceLength; i) { // 点亮当前序列位置对应的 LED digitalWrite(ledPins[sequence[i]], HIGH); // 同时让蜂鸣器发对应音调 tone(buzzerPin, noteFreq[sequence[i]]); delay(400); // 熄灭 LED 并停止发声 digitalWrite(ledPins[sequence[i]], LOW); noTone(buzzerPin); delay(150); } } // 读取一次有效的按键按下事件 // 返回值被按下的颜色编号0~3如果没有按键则返回 -1 int readButtonPress() { // 先做一轮扫描 for (int i 0; i 4; i) { if (digitalRead(buttonPins[i]) LOW) { // 等待 30 毫秒跳过按键抖动 delay(30); // 再次确认是否还是按下状态 if (digitalRead(buttonPins[i]) LOW) { // 防止一次按下被重复读取等按键松开再返回 while (digitalRead(buttonPins[i]) LOW) { delay(10); } return i; } } } return -1; } // 按下按键时让对应 LED 亮一下给玩家反馈 void flashLed(int index) { digitalWrite(ledPins[index], HIGH); tone(buzzerPin, noteFreq[index]); delay(150); digitalWrite(ledPins[index], LOW); noTone(buzzerPin); delay(50); } // 游戏失败处理闪烁 3 次然后自动重置 void handleGameOver() { // 失败动画四个 LED 同时闪烁 3 次 for (int i 0; i 3; i) { for (int j 0; j 4; j) { digitalWrite(ledPins[j], HIGH); } tone(buzzerPin, 200); delay(300); for (int j 0; j 4; j) { digitalWrite(ledPins[j], LOW); } noTone(buzzerPin); delay(300); } // 重置所有游戏数据 sequenceLength 0; inputIndex 0; gameOver false; // 重新开始 startNewRound(); }3.3 代码阅读顺序从哪一行看起如果你第一次接触这段代码我建议不要从头到尾按顺序读而是按照这个路径先看setup()了解引脚和随机种子初始化。再看loop()搞清楚主循环的“三件事”播放序列、读按键、校验结果。然后看playSequence()理解主机演示的实现。接着看readButtonPress()这是硬件项目里最容易写错的地方。最后看startNewRound()和handleGameOver()理解游戏如何循环。loop()是整个程序的主干它每一遍都在反复执行同样的判断这就是嵌入式程序最常见的“超级循环”结构。4. 实测排错我踩过的四个坑4.1 按键抖动不处理的话按一次等于按好几次机械按键的物理特性决定了按下和松开的瞬间触点会快速通断好几次这个过程叫“抖动”。如果你不加处理Arduino 会把一次按压误判成多次导致inputIndex一次跳好几格。我代码里的处理是“两次确认 等待松开”if (digitalRead(buttonPins[i]) LOW) { delay(30); if (digitalRead(buttonPins[i]) LOW) { while (digitalRead(buttonPins[i]) LOW) { delay(10); } return i; } }第一次检测到按下先等 30ms 让抖动过去第二次检测如果还是按下状态说明确实是有效按压。while循环等待按键松开是为了防止一次按住不放导致loop()反复触发同一次输入。这个“按住不松手”的问题我在实际测试时踩过刚开始没加松开等待只要玩家手指按得稍微久了点程序就会把同一位连续校验两次结果明明按对了却说失败。4.2 随机数不随机不播种子序列永远一模一样Arduino 的random()函数如果不在setup()里调用randomSeed()每次上电生成的随机序列其实是完全相同的。第一轮可能都是红色、第二轮都是蓝红、第三轮都是红绿蓝…… 玩一次能发现猫腻。播种最常见的方法是读取一个悬空模拟引脚的噪声值randomSeed(analogRead(A0));A0 引脚什么都不接时读到的值会受环境电磁干扰影响产生微小的波动这足以作为随机种子使用。这个用法在 Arduino 项目里是非常通用的。4.3 输入阶段把“上一次的按键”带到了下一轮还有一个很隐蔽的 bug玩家快速连续通过两轮时如果最后一轮按键刚松开loop()下一圈又立刻进入了播放序列阶段此时readButtonPress()有可能读到上一次按键的残留电平。我的解决思路是每轮开始播放序列之前先把inputIndex归零并且确认所有按键处于松开状态。如果你发现自己的程序偶尔“自己跳过一位”多半是这个问题。可以在startNewRound()里加一个delay(100)给玩家一点缓冲时间别让状态切换得太急促。4.4 蜂鸣器和 LED 互相干扰如果你用的无源蜂鸣器注意tone()函数需要接到能输出 PWM 的引脚上。Arduino Uno 的 3、5、6、9、10、11 都可以但我代码里把蜂鸣器放在 6 号按键放在 2、3、4、5这是因为 PWM 引脚和普通数字引脚分开放接线更清晰。实际测试中我还遇到过一个问题蜂鸣器发声时如果直接共用一个电源电流波动会让 LED 亮度发生轻微闪烁。解决办法很简单蜂鸣器加一个小电容滤波或者用独立电源给蜂鸣器供电。做原型验证时加一个 100μF 电容并在电源两端能省掉不少烦恼。5. 从“能玩”到“好玩”可落地的扩展方向5.1 增加 OLED 计分板现在很多教程止步于“四个灯四个键”但游戏体验其实很弱因为玩家不知道现在到第几轮了。你可以买一块 0.96 英寸的 OLED 显示屏I2C 接口接在 A4SDA、A5SCL上然后在startNewRound()里把sequenceLength显示成“Round: 3”。用U8g2库十几行代码就能搞定。这是所有扩展里性价比最高的一个强烈推荐。5.2 动态调整播放速度原始 Simon 游戏会随着轮数增加而不断加快序列播放速度制造压迫感。代码上只需要让每次播放的delay根据sequenceLength动态计算int playDelay 500 - (sequenceLength * 20); if (playDelay 200) playDelay 200;注意设一个下限不然到了后面几轮序列像机关枪一样闪完玩家根本来不及看游戏就瞬间失去平衡了。5.3 从硬件移植到 Web 端如果你后面想把这个项目变成真正的完整产品或者想给朋友在线玩可以考虑用 JavaScript 重写一遍。核心状态机逻辑完全不用改把digitalWrite换成修改 DOM 元素的 CSS 类把tone()换成 Web Audio API 的OscillatorNode按键部分换成click事件即可。我做这个移植时最大的体会是只要状态机划分得清楚换平台只是替换“输入输出层”游戏逻辑层可以原封不动搬过去。这也是为什么我建议初学者在写 Arduino 版本时一定要把playSequence()、readButtonPress()、handleGameOver()这些函数分开写而不是全塞在loop()里。5.4 加入“严格模式”和音效主题如果你想让游戏更有辨识度可以给每个颜色配一段更长的旋律而不是单纯的一个单音。我试过把四个颜色分别对应《超级玛丽》里的四个音符玩家在按键时会有很强的“对上了”的听觉反馈玩起来比单音有趣得多。还可以加入严格模式不追加新颜色而是每次随机生成一条全新序列长度固定为 8 或 10 步。这个模式比拼的是瞬间记忆能力和原版玩法完全是两种体验。代码上只需要把startNewRound()里“追加”改成“全部重新生成”即可。最后再分享一点个人体会这个项目我前后做了三版第一版把所有逻辑堆在loop()里改一个功能就要牵动全局调试痛苦第二版开始按函数拆分但状态切换还是靠一堆零散的if嵌套偶尔还是出 bug第三版才真正意识到“状态机”这个抽象的价值。如果你做这个项目时也遇到“明明逻辑没错但就是乱跑”的情况先别急着加代码停下来画一张状态切换图把“谁允许在什么时候做什么”理清楚问题往往就自己浮现出来了。本文还有配套的精品资源点击获取