news 2026/5/28 15:09:42

Arduino记忆游戏:从I2C LCD、PWM渐灭到状态机编程的嵌入式实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Arduino记忆游戏:从I2C LCD、PWM渐灭到状态机编程的嵌入式实践

1. 项目概述与核心价值

最近在整理工作室的物料,翻出来一堆闲置的Arduino Uno、LED和按钮,琢磨着得做个什么把它们用起来。与其让它们继续吃灰,不如动手做个能玩、能学、还有点挑战性的小玩意儿。于是,这个基于Arduino的记忆游戏项目就诞生了。它不仅仅是一个简单的“看灯按按钮”游戏,我给它加入了LCD显示控制来提供清晰的游戏状态反馈,用电位器实现了难度调节机制,让挑战可以随心所欲,最让我觉得有意思的是那个LED渐灭计时器——它不像普通的数字倒计时那样生硬,而是用一种更直观、更紧张的光影变化来提醒你时间正在流逝。

这个项目非常适合已经摸过Arduino基础(比如点亮LED、读取按钮)但想更进一步的朋友。你会接触到如何用I2C驱动LCD屏显示自定义信息,如何用模拟输入(电位器)来动态改变游戏逻辑参数,以及如何用PWM(脉冲宽度调制)控制LED亮度来实现一个平滑的视觉计时器。整个过程就像搭积木,把嵌入式系统实践中的几个关键模块——输入、处理、输出——有机地组合成一个完整的交互式硬件项目。做完之后,你对Arduino的引脚操作、状态机编程和实时交互设计会有更扎实的理解。

2. 硬件设计与核心思路拆解

2.1 整体架构与模块化设计

这个记忆游戏的核心玩法是经典的“西蒙说”(Simon Says):系统生成一个随机的LED点亮序列,玩家需要观察并按照相同顺序按下对应的按钮。我们的硬件设计就是围绕这个玩法,构建输入、输出、控制和反馈四个模块。

输入模块:由四个常开型轻触按钮构成玩家的操作接口。每个按钮都需要一个上拉电阻(这里用的是10kΩ),确保在未按下时,Arduino读取到的引脚状态是稳定的高电平,避免因引脚悬空导致的误触发。按钮的另一端接地,按下时,引脚被拉低到GND,Arduino读取到低电平,从而判定为一次按键动作。

核心输出模块:即四个游戏LED,分别对应四个按钮。我建议使用不同颜色的LED(例如红、绿、黄、蓝),这样在视觉上更容易区分。每个LED串联一个330Ω的限流电阻,直接由Arduino的数字引脚驱动。这个电阻值是基于Arduino引脚约5V输出电压和LED典型正向压降(约1.8V-2.2V)及工作电流(通常希望控制在10-20mA)计算得出的:R = (5V - 2V) / 0.015A ≈ 200Ω。选用330Ω是一个更保守、安全且常见的值,能有效保护LED和Arduino引脚。

辅助输出与反馈模块:这部分是项目的亮点所在。

  1. LCD显示屏:采用I2C接口的16x2字符LCD模块。I2C总线只需要两根信号线(SDA, SCL)和电源线,极大简化了布线。它的作用是提供文本化的游戏状态信息,比如“Round 1”、“Get Ready!”、“Correct!”、“Time's Up!”,让游戏交互脱离“盲操”,体验更友好。
  2. 渐灭计时LED:这是一个独立的LED(比如白色),连接在支持PWM(脉宽调制)输出的引脚上(如引脚10)。它的亮度不是简单的亮或灭,而是会随着玩家思考时间的减少而平滑降低,形成一个直观的“沙漏”视觉效果。
  3. 蜂鸣器:用于提供音频反馈。游戏开始、成功、失败、进入极端模式等关键事件都会伴随不同的音效,增强游戏的沉浸感。

控制与调节模块

  1. Arduino Uno:作为大脑,负责运行游戏逻辑、读取所有输入、控制所有输出。
  2. 电位器:一个模拟输入元件,连接至模拟引脚A0。旋转电位器,中间抽头的电压会在0V-5V之间变化,Arduino的ADC(模数转换器)将其映射为0-1023的整数值。这个值将直接决定游戏的难度参数,如序列长度、显示时间和输入响应时间。

注意:电源规划。所有元件的电源(5V)和地(GND)最好通过面包板的电源轨进行统一分配,确保供电稳定。特别是当多个LED同时点亮时,要确保电源能提供足够电流。虽然本项目耗电不大,但养成好的布线习惯很重要。

2.2 核心交互逻辑:状态机思维

在软件层面,我们采用“状态机”的思维来设计游戏流程。整个游戏可以划分为几个明确的状态:

  1. 初始化状态:硬件自检,LCD显示欢迎语。
  2. 难度选择状态:读取电位器值,计算难度等级,并在LCD上显示倒计时确认。
  3. 游戏进行状态
    • 子状态-演示模式:系统按生成的序列点亮LED,玩家观察。
    • 子状态-输入模式:玩家按按钮输入,同时计时LED开始渐灭。
    • 子状态-校验模式:判断玩家输入是否正确。
  4. 结果判定状态:根据校验结果,进入“胜利”或“失败”状态,播放相应音效和灯光效果,等待重启。

状态之间的转换由条件触发,比如“难度选择倒计时结束”进入“游戏进行状态”,“玩家输入错误或超时”进入“失败状态”。用代码实现时,通常会使用一个全局变量(如gameState)来标记当前状态,并在loop()函数中用switch-case语句根据不同的状态执行相应的代码块。这种结构清晰,易于调试和扩展。

3. 电路搭建与核心元件连接详解

3.1 基础供电与总线铺设

首先,将Arduino Uno放置在面包板左侧。用一根红色跳线将Arduino的5V引脚连接到面包板一侧的红色正极电源轨。用一根黑色跳线将Arduino的GND引脚连接到面包板同一侧的蓝色/黑色负极电源轨。然后,再用两根跳线(一红一黑)将面包板这一侧的电源轨和地轨延伸到另一侧,确保整个面包板上下两部分的供电是连通的。这是所有电子项目稳健的第一步。

3.2 游戏按钮与LED阵列的配对连接

这是硬件部分的核心。我们采用“按钮与LED纵向对齐,横向排列”的布局,方便玩家识别对应关系。

  1. 放置元件:在面包板中部,纵向放置四个轻触按钮,彼此间隔2-3个孔位。在每个按钮的正下方(同一列),放置对应的LED,注意LED的长脚(阳极,+)在上,短脚(阴极,-)在下。
  2. 按钮上拉电路:每个按钮的一个引脚(假设是左侧引脚)通过一个10kΩ电阻连接到正极电源轨(5V)。这个电阻就是上拉电阻。该按钮的同一侧引脚(即连接上拉电阻的引脚)还需要用一根信号线连接到Arduino的数字输入引脚(例如引脚2、3、4、5)。按钮的另一个引脚(右侧)则直接连接到地轨(GND)。这样,未按下时,输入引脚被上拉到高电平;按下时,引脚直接接地变为低电平。
  3. LED驱动电路:每个LED的阴极(短脚,-)直接插入地轨。每个LED的阳极(长脚,+)则先连接一个330Ω的限流电阻,电阻的另一端用跳线连接到Arduino的数字输出引脚(例如引脚6、7、8、9)。这样,当Arduino将对应引脚设为HIGH(输出5V)时,电流从引脚流出,经电阻、LED到地,LED点亮。

3.3 LCD、电位器与辅助元件的集成

  1. I2C LCD模块:连接非常简单。找到模块背面的4个引脚:GND接面包板地轨,VCC接电源轨(5V),SDA接Arduino Uno的A4引脚(或标有SDA的引脚),SCL接Arduino Uno的A5引脚(或标有SCL的引脚)。I2C通信协议的好处就是线少。
  2. 电位器:这是一个三端元件。将它的左右两脚分别接电源轨(5V)和地轨(GND)。中间抽头(滑动端)接Arduino的模拟输入引脚A0。旋转旋钮,A0读取的电压值就会变化。
  3. 渐灭计时LED:取一个LED(如白色),其阴极通过一个330Ω电阻接地轨。阳极连接至Arduino的10号引脚(这是一个支持PWM的引脚,旁边标有~符号)。
  4. 蜂鸣器:有源蜂鸣器有正负极性。负极(通常引脚较短或有标记)通过一个100Ω电阻接地轨(这个电阻用于稍微降低音量,防止过于刺耳)。正极直接连接到Arduino的11号数字引脚。

实操心得:布线整洁之道。在连接这么多线时,尽量使用不同颜色的跳线区分功能:红色用于5V,黑色或蓝色用于GND,黄色或绿色用于信号线。电源和地线尽量沿着面包板边缘的电源轨走,信号线在中间区域连接。完成所有连接后,务必对照电路图或原理图再检查一遍,特别是LED和蜂鸣器的极性、电阻值是否正确,避免接反烧毁元件。

4. 软件实现:代码结构与核心逻辑剖析

4.1 开发环境配置与库引入

首先确保你安装了Arduino IDE。代码的核心是LiquidCrystal_I2C库,它简化了I2C LCD的控制。如果尚未安装,在IDE中点击工具->管理库...,在搜索框中输入“LiquidCrystal_I2C”,找到由Frank de Brabander开发的版本进行安装。这个库封装了向LCD发送命令和数据的复杂过程。

代码开头是引脚定义和对象初始化。这里我们为每个硬件元素定义一个易于理解的常量名。

#include <Wire.h> #include <LiquidCrystal_I2C.h> // 游戏LED和按钮引脚定义 (LED引脚需支持PWM的可用作计时LED,这里用普通数字口) const int buttonPins[] = {2, 3, 4, 5}; // 四个按钮对应的输入引脚 const int ledPins[] = {6, 7, 8, 9}; // 四个游戏LED对应的输出引脚 const int potPin = A0; // 电位器模拟输入引脚 const int timerLedPin = 10; // 渐灭计时LED引脚 (需支持PWM) const int buzzerPin = 11; // 蜂鸣器引脚 // 初始化LCD对象,参数通常为(0x27, 16, 2),但地址可能是0x3F,需用I2C扫描器确认 LiquidCrystal_I2C lcd(0x27, 16, 2); // 游戏变量 int pattern[30]; // 存储生成的LED序列,最多30步 int currentRound = 1; int maxRounds; int difficultyLevel; bool gameActive = false; bool extremeMode = false;

4.2 难度选择与参数映射逻辑

setup()函数初始化引脚和LCD后,游戏首先进入难度选择阶段。map()函数是这里的关键。

void selectDifficulty() { lcd.clear(); lcd.print("Adjust Difficulty"); lcd.setCursor(0, 1); lcd.print("Then Wait..."); delay(2000); // 给玩家时间调整电位器 lcd.clear(); lcd.print("Starting in:"); for (int i = 3; i > 0; i--) { lcd.setCursor(0, 1); lcd.print(" "); lcd.setCursor(0, 1); lcd.print(i); delay(1000); } // 读取并映射难度 int potValue = analogRead(potPin); // 读取值 0-1023 // 将电位器值映射为1-20的难度等级 difficultyLevel = map(potValue, 0, 1023, 1, 20); // 根据难度等级计算最大回合数和回合时间 maxRounds = map(difficultyLevel, 1, 20, 5, 30); // 最简单5步,最难30步 // 检查是否触发极端模式(最高难度) if (difficultyLevel == 20) { extremeMode = true; lcd.clear(); lcd.print("EXTREME MODE!"); playExtremeTone(); delay(1500); } }

这里的逻辑是:在3秒倒计时期间,玩家旋转电位器。倒计时结束后,程序读取A0的稳定值,并将其从0-1023的范围线性映射到1-20的难度等级。难度等级越高,maxRounds(需要记忆的总步数)就越多。当检测到难度为最大值20时,设置extremeMode标志为真,并触发特殊提示。

4.3 序列生成、演示与玩家输入校验

游戏的核心循环在loop()中,但我们将主要逻辑封装成函数以便管理。

序列生成:使用random()函数生成随机数,但需用randomSeed(analogRead(A5))读取一个未连接的模拟引脚噪声作为随机种子,确保每次上电序列不同。

void generatePattern() { randomSeed(analogRead(A5)); // 使用悬空模拟引脚获取随机种子 for (int i = 0; i < maxRounds; i++) { pattern[i] = random(0, 4); // 生成0-3的随机数,对应4个LED/按钮 } }

演示模式:系统按顺序点亮pattern数组中存储的LED序列。点亮时间和间隔时间可以根据难度或是否极端模式进行调整。

void playPattern(int upToRound) { for (int i = 0; i < upToRound; i++) { int ledIndex = pattern[i]; digitalWrite(ledPins[ledIndex], HIGH); if (extremeMode) { delay(150); // 极端模式显示时间更短 } else { delay(400 - (difficultyLevel * 10)); // 难度越高,显示时间越短 } digitalWrite(ledPins[ledIndex], LOW); delay(200); // 灯灭间隔 } }

输入模式与渐灭计时器:这是最复杂的部分。我们需要在一个有限的时间内循环检测玩家输入,同时让计时LED的亮度随时间平滑下降。

bool getPlayerInput(int roundNumber) { unsigned long startTime = millis(); // 记录输入开始时间 int maxTime = calculateMaxTime(); // 根据难度计算允许的最大输入时间(毫秒) int inputIndex = 0; // 记录玩家当前应输入的第几步 while (inputIndex < roundNumber) { // 1. 计算并更新渐灭LED亮度 unsigned long elapsedTime = millis() - startTime; if (elapsedTime >= maxTime) { // 时间到,输入失败 return false; } // 将已过去时间映射为亮度值(255为最亮,0为最暗) // 时间越久,亮度越低 int brightness = map(elapsedTime, 0, maxTime, 255, 0); brightness = constrain(brightness, 0, 255); // 限制在0-255范围内 analogWrite(timerLedPin, brightness); // PWM输出控制亮度 // 2. 检测按钮输入 int buttonPressed = checkButtonPress(); if (buttonPressed != -1) { // 有按钮被按下 // 点亮对应的游戏LED作为反馈 digitalWrite(ledPins[buttonPressed], HIGH); delay(200); digitalWrite(ledPins[buttonPressed], LOW); // 校验输入是否正确 if (buttonPressed == pattern[inputIndex]) { inputIndex++; // 输入正确,准备接收下一个 startTime = millis(); // 重置该步输入的计时器 maxTime = calculateMaxTime(); // 极端模式下每步时间可能不同,重新计算 } else { // 输入错误 return false; } } // 短暂延迟防止循环过速 delay(10); } // 所有输入正确完成 return true; }

checkButtonPress()函数需要实现去抖动处理,这是读取机械按钮的必备步骤。

int checkButtonPress() { for (int i = 0; i < 4; i++) { if (digitalRead(buttonPins[i]) == LOW) { // 按钮按下为低电平 delay(50); // 简单延时去抖 if (digitalRead(buttonPins[i]) == LOW) { // 确认按下 while(digitalRead(buttonPins[i]) == LOW) { // 等待按钮释放,避免一次按下被多次读取 } return i; // 返回按下的按钮索引 (0-3) } } } return -1; // 没有按钮被按下 }

4.4 极端模式与游戏状态收尾

extremeMode为真时,需要在游戏的各个阶段应用更严苛的参数。我们可以修改calculateMaxTime()函数和playPattern()中的延时参数。

int calculateMaxTime() { int baseTime = 2000; // 基础时间2秒 int timeReduction = difficultyLevel * 50; // 难度越高,时间越少 int finalTime = baseTime - timeReduction; if (extremeMode) { finalTime = finalTime / 2; // 极端模式下时间减半 // 还可以让蜂鸣器音调更高 tone(buzzerPin, 800, 100); // 一个高音提示 } return constrain(finalTime, 500, 2000); // 确保时间在0.5秒到2秒之间 }

胜利youWin()和失败youLose()函数主要负责提供视听反馈。例如,失败时可以让所有LED快速闪烁,播放一段下降音调;胜利时可以让LED依次流水灯式点亮,播放一段欢快的上升音调。同时,LCD显示相应信息,并调用一个waitForRestart()函数,该函数循环检测任意按钮被按下,然后重置所有游戏变量,重新开始。

5. 调试技巧、常见问题与优化建议实录

5.1 上电无反应或LCD不显示

这是最常见的问题。请按以下顺序排查:

  1. 电源检查:首先确认Arduino的电源指示灯(ON)是否亮起。用万用表测量面包板电源轨和地轨之间的电压是否为5V左右。
  2. I2C地址问题LiquidCrystal_I2C库需要正确的I2C设备地址。0x27是常见地址,但也可能是0x3F或其它。可以运行一个简单的I2C扫描程序来确认地址。
    #include <Wire.h> void setup() { Wire.begin(); Serial.begin(9600); while (!Serial); Serial.println("I2C Scanner..."); } void loop() { byte error, address; int nDevices = 0; for(address = 1; address < 127; address++ ) { Wire.beginTransmission(address); error = Wire.endTransmission(); if (error == 0) { Serial.print("I2C device found at address 0x"); if (address<16) Serial.print("0"); Serial.print(address,HEX); Serial.println(" !"); nDevices++; } } if (nDevices == 0) Serial.println("No I2C devices found"); delay(5000); }
    将扫描到的地址替换到LiquidCrystal_I2C lcd(ADDRESS, 16, 2);中。
  3. 对比度调节:很多LCD模块背面有一个蓝色的电位器,用于调节显示对比度。如果显示全黑方块或完全空白,尝试用小螺丝刀旋转这个电位器。
  4. 接线复查:这是硬件项目的永恒主题。再次逐根检查所有跳线是否连接牢固,是否插在了正确的行和列,特别是LCD的SDASCL是否与Arduino的对应引脚接反。

5.2 按钮响应不灵或LED异常

  1. 按钮抖动:机械按钮在按下和释放的瞬间会产生快速的通断抖动,可能导致一次物理按压被程序误判为多次按下。我们的checkButtonPress()函数中使用了简单的延时去抖。如果感觉仍有问题,可以尝试增加去抖延时(如delay(100)),或者实现更优秀的“状态机去抖”或“边沿检测”算法。
  2. LED不亮或常亮
    • 不亮:检查LED极性是否接反(长脚阳极接正)。用万用表二极管档或直接用一个3V电池(纽扣电池)配合一个电阻测试LED本身是否完好。
    • 常亮:检查对应的Arduino引脚是否在代码中被正确设置为OUTPUT模式。检查按钮电路,如果按钮的上拉电阻没接好或按钮损坏导致常闭,也可能使得控制LED的引脚被意外拉低或拉高(取决于电路设计)。
  3. 多个LED同时微弱发光:这通常是“鬼影”现象。当某个引脚被设置为INPUT模式(高阻抗状态)时,它很容易受到附近信号线的电磁干扰。确保所有未使用的、连接到LED的引脚,在setup()中都明确设置为OUTPUT,并在初始状态设为LOW

5.3 游戏逻辑Bug与性能优化

  1. 随机序列总是相同random()函数如果不播种(randomSeed),每次程序启动生成的序列是相同的。我们使用了analogRead(A5)来播种,因为A5引脚悬空时会读取环境电磁噪声,是一个不错的随机源。确保A5引脚没有连接任何东西。
  2. 渐灭LED亮度变化不平滑或卡顿analogWrite()函数本身很高效。卡顿可能源于loop()中其他耗时操作,比如没有使用millis()的非阻塞延时,而是用了delay()。确保在getPlayerInput()while循环中,除了必要的去抖延时,避免使用长延时。所有时间判断都应基于millis()的差值计算。
  3. 蜂鸣器声音沙哑或音量不可控:有源蜂鸣器直接接digitalWritetone()函数驱动即可。如果声音奇怪,检查正负极是否接反。串联的100Ω电阻可以有效降低音量,如果觉得太吵可以增大电阻值(如220Ω),如果觉得太轻可以减小电阻值或直接短接(但注意不要超过引脚电流限制)。
  4. 极端模式感觉不够“极端”:可以进一步压榨性能。比如,将演示模式的LED点亮时间从150ms降到80ms;将输入超时时间计算函数中的finalTime = finalTime / 2;改为finalTime = finalTime / 3;;甚至可以在极端模式下,让生成的序列长度maxRounds额外增加50%。这些参数都可以在代码顶部定义为常量,方便调整测试。

5.4 项目扩展与创意优化方向

这个基础框架有很大的扩展空间:

  • 增加分数系统:在LCD第二行实时显示当前得分。得分规则可以是:基础分每轮递增,剩余时间越多奖励分越高,连续正确有连击倍率。
  • 多种游戏模式:除了经典的“重复序列”,可以增加“反向重复”、“隔位重复”等模式,通过增加一个模式选择按钮来切换。
  • 音效多样化:为每个LED分配不同的音调,在演示和玩家按下时播放,形成“光声同步”记忆,增加难度也更有趣。
  • 使用RGB LED:将四个单色LED换成全彩RGB LED。游戏胜利时可以显示彩虹渐变,失败时显示红色呼吸灯,视觉效果大幅提升。
  • 数据持久化:加入EEPROM存储,记录历史最高分、最快通关时间等,激发玩家的挑战欲。
  • 外壳设计:用激光切割亚克力板或3D打印一个外壳,将面包板电路移植到洞洞板或定制PCB上进行焊接,做一个真正可以放在桌面上玩的成品。

调试这类交互项目,串口监视器是你的最佳朋友。在代码关键位置(如读取到电位器值、生成序列、检测到按钮按下时)添加Serial.print()语句输出变量状态,能让你清晰地看到程序内部的运行逻辑,快速定位问题所在。硬件项目就是不断“假设-验证-调整”的过程,遇到问题别慌,分段测试,从电源到信号一步步缩小范围,最终总能让它按照你的想法运行起来。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/5/28 15:09:11

2026牛客网大厂Java面试真题+答案解析(建议直接收藏)

如果你认为2025年的Java面试只是“卷学历、卷项目、卷源码”&#xff0c;那么2026年的面试场&#xff0c;将是一场对人类程序员“护城河”的终极审判。 传统的Java面试困境依旧存在&#xff1a;JVM调优要背几十种参数、并发编程要精通AQS的每一行源码、海量数据场景要手写SQL优…

作者头像 李华
网站建设 2026/5/28 15:08:33

冰雪传奇点卡版下载官方正版入口:行会与团战玩法 兄弟并肩共战沙城

传奇游戏的魅力不仅在于打宝和升级&#xff0c;更在于和兄弟一起并肩作战的热血与激情。行会作为传奇游戏中最重要的社交组织&#xff0c;是玩家结交朋友、组队战斗的平台。冰雪传奇点卡版保留了经典的行会系统和沙巴克攻城战玩法&#xff0c;让玩家能够体验到最纯粹的团队战斗…

作者头像 李华
网站建设 2026/5/28 15:06:37

AI时代Robots.txt分析器:精准测试GPTBot等爬虫规则

1. 项目概述&#xff1a;为什么你需要一个专业的Robots.txt分析器如果你负责过网站运维、SEO或者内容策略&#xff0c;那么robots.txt这个文件对你来说一定不陌生。它就像是你网站门口的一块“访客须知”告示牌&#xff0c;告诉各种网络爬虫&#xff08;比如Googlebot、Bingbot…

作者头像 李华
网站建设 2026/5/28 15:06:02

MySQL gtid_mode 双主复制配置,基于MySQL8.4.3

GTID 会自动比对已执行的事务集&#xff0c;自动定位从哪里开始同步配置my.cnf[mysqld] server-id1 # 必须唯一&#xff0c;多台服务器之间必须不一样 log-binmysql-bin # 启用二进制日志 binlog-formatROW # 推荐使用ROW格式 innodb_flush_log_at_trx_commit1 # 确保事务安…

作者头像 李华