Arduino LCD跑酷游戏开发:状态机与I2C通信实战解析
1. 项目概述与核心思路
几年前,我刚开始玩Arduino的时候,总觉得它只能做些流水灯、温湿度计之类的小玩意儿,离真正的“游戏开发”很远。直到有一次,我偶然看到国外一个极客用一块1602的LCD屏和一个按键,就做出了一个能玩的跑酷游戏,当时就被震撼到了。这不就是我们常说的“在有限资源下创造无限可能”吗?这个项目完美诠释了嵌入式开发的魅力:用最精简的硬件(一块Arduino Uno,一个LCD I2C模块,一个按键),实现一个完整的、有交互、有逻辑的游戏。它不仅仅是一个玩具,更是一个绝佳的学习案例,能让你深刻理解状态机、帧动画、中断处理以及内存优化这些嵌入式开发的核心概念。
这个“Arduino无尽跑酷游戏”的核心玩法很简单:一个由字符像素构成的小人在LCD屏幕上不断向右奔跑,屏幕上的地形(障碍物)会从右向左滚动。玩家需要在小人遇到障碍物时,及时按下按键使其跳跃,从而躲避障碍。游戏会记录小人跑过的距离作为分数,一旦碰撞发生,游戏结束。麻雀虽小,五脏俱全。要实现它,我们需要解决几个关键问题:如何在只有两行16列的LCD上流畅地显示动态画面?如何用一个按键精准地控制跳跃时机?如何高效地管理游戏中的各种状态(奔跑、跳跃、碰撞)?接下来,我将带你从硬件选型、电路连接,到代码的逐行解析,最后再到调试优化,完整地复现这个项目,并分享我在实现过程中踩过的坑和总结的经验。
2. 硬件选型与电路连接解析
2.1 核心硬件清单与选型理由
这个项目的硬件清单极其精简,但每一件都经过深思熟虑,目的是在保证功能的前提下,最大程度地降低成本和复杂度。
Arduino Uno R3 (x1)
- 理由:这是Arduino家族中最经典、资源最适中的型号。它拥有14个数字I/O口、6个模拟输入口、32KB的Flash存储器和2KB的SRAM,对于这个游戏来说绰绰有余。其16MHz的主频足以处理游戏逻辑和屏幕刷新。对于初学者来说,Uno的生态最完善,资料最多,遇到问题也最容易找到解决方案。
1602 LCD 显示屏 + I2C适配模块 (x1)
- 理由:这是本项目的关键优化点。传统的1602 LCD需要连接至少6根线(RS, EN, D4, D5, D6, D7, VCC, GND),而I2C模块将其简化为仅需4根线(VCC, GND, SDA, SCL)。I2C模块本质上是一个“翻译官”,它把Arduino通过I2C协议发送的指令,转换成1602 LCD能理解的并行数据。这极大地节省了宝贵的I/O口,并简化了布线。注意:市面上常见的I2C模块地址通常是
0x27或0x3F,代码中需要对应修改。
- 理由:这是本项目的关键优化点。传统的1602 LCD需要连接至少6根线(RS, EN, D4, D5, D6, D7, VCC, GND),而I2C模块将其简化为仅需4根线(VCC, GND, SDA, SCL)。I2C模块本质上是一个“翻译官”,它把Arduino通过I2C协议发送的指令,转换成1602 LCD能理解的并行数据。这极大地节省了宝贵的I/O口,并简化了布线。注意:市面上常见的I2C模块地址通常是
轻触开关/按键 (x1)
- 理由:用于控制小人跳跃。选择轻触开关是因为它手感清晰,触发明确。我们将其一端接地(GND),另一端通过一个上拉电阻连接到Arduino的数字引脚(代码中为D2)。当按键未按下时,引脚被上拉到高电平(
HIGH);按下时,引脚被拉低到低电平(LOW)。这种设计可以有效避免引脚悬空导致的电平飘忽问题。
- 理由:用于控制小人跳跃。选择轻触开关是因为它手感清晰,触发明确。我们将其一端接地(GND),另一端通过一个上拉电阻连接到Arduino的数字引脚(代码中为D2)。当按键未按下时,引脚被上拉到高电平(
杜邦线 (若干)
- 理由:用于连接所有组件。建议使用公对公的杜邦线,方便在面包板或直接插接。
注意:如果你手头的LCD I2C模块背面有一个蓝色的可调电位器,那是用来调节LCD对比度的。如果上电后屏幕有背光但无显示,或显示一片黑块,首要操作就是用小螺丝刀微调这个电位器,直到字符清晰显示。
2.2 电路连接详解与原理图
连接遵循“电源先行,信号后接”的原则。下图清晰地展示了所有连接关系:
Arduino Uno <--> 外部组件 ----------------------------- 5V <--> LCD I2C模块 VCC GND <--> LCD I2C模块 GND, 按键一端 A4 (SDA) <--> LCD I2C模块 SDA A5 (SCL) <--> LCD I2C模块 SCL D2 <--> 按键另一端 (通过内部上拉电阻)连接步骤与原理说明:
供电部分:将Arduino的
5V和GND分别连接到LCD I2C模块的VCC和GND引脚。这为LCD屏幕和其背光提供了电源。同时,将Arduino的GND也连接到按键的一个引脚。I2C通信部分:这是核心。Arduino Uno的A4引脚固定作为I2C的数据线(SDA),A5引脚固定作为时钟线(SCL)。将它们分别连接到I2C模块对应的
SDA和SCL引脚。I2C是一种同步、半双工、多主从的串行总线,这两根线就承载了所有发送给LCD的指令和数据,效率非常高。按键输入部分:将按键的另一个引脚连接到Arduino的数字引脚2(D2)。在代码中,我们会将D2配置为
INPUT_PULLUP模式,这意味着Arduino内部会自动连接一个上拉电阻到该引脚。因此,无需外接电阻。当按键按下,D2与GND连通,电平被拉低,产生一个下降沿信号,我们通过外部中断来捕获这个信号,确保跳跃响应的实时性。
实操心得:焊接I2C模块到LCD屏时,务必确认方向。通常I2C模块的排针应对准LCD屏背面的16个焊盘。如果焊接反了或虚焊,会导致通信失败。第一次连接后,可以先上传一个简单的LCD显示例程(如
Hello World)来测试硬件和地址是否正确,再进入复杂的游戏逻辑,这样可以有效隔离问题。
3. 游戏代码深度解析与实现
代码是这个项目的灵魂。它虽然只有几百行,但蕴含了嵌入式游戏开发的典型设计模式。我们将分模块进行拆解。
3.1 头文件、宏定义与全局变量
#include <LiquidCrystal_I2C.h> #include <Wire.h> // 引脚定义 #define PIN_BUTTON 2 #define PIN_AUTOPLAY 1 // 本例中未实际使用,可忽略 #define PIN_READWRITE 10 // 本例中未实际使用,可忽略 #define PIN_CONTRAST 7 // 本例中未实际使用,可忽略 // 精灵(图形)定义 #define SPRITE_RUN1 1 #define SPRITE_RUN2 2 #define SPRITE_JUMP 3 #define SPRITE_JUMP_UPPER '.' #define SPRITE_JUMP_LOWER 4 #define SPRITE_TERRAIN_EMPTY ' ' #define SPRITE_TERRAIN_SOLID 5 #define SPRITE_TERRAIN_SOLID_RIGHT 6 #define SPRITE_TERRAIN_SOLID_LEFT 7 // 游戏常量 #define HERO_HORIZONTAL_POSITION 1 // 小人固定的水平位置(第2列) #define TERRAIN_WIDTH 16 // 地形宽度,等于LCD宽度 // 地形类型 #define TERRAIN_EMPTY 0 #define TERRAIN_LOWER_BLOCK 1 // 下方障碍 #define TERRAIN_UPPER_BLOCK 2 // 上方障碍 // 小人状态(这是一个状态机!) #define HERO_POSITION_OFF 0 #define HERO_POSITION_RUN_LOWER_1 1 // 下层奔跑姿态1 #define HERO_POSITION_RUN_LOWER_2 2 // 下层奔跑姿态2(实现动画) #define HERO_POSITION_JUMP_1 3 // 跳跃起始 #define HERO_POSITION_JUMP_2 4 // 上升中 #define HERO_POSITION_JUMP_3 5 // 在上层 #define HERO_POSITION_JUMP_4 6 #define HERO_POSITION_JUMP_5 7 #define HERO_POSITION_JUMP_6 8 #define HERO_POSITION_JUMP_7 9 // 下降中 #define HERO_POSITION_JUMP_8 10 // 即将落地 #define HERO_POSITION_RUN_UPPER_1 11 // 上层奔跑姿态1 #define HERO_POSITION_RUN_UPPER_2 12 // 上层奔跑姿态2 // 初始化LCD对象,地址0x27,16列2行 LiquidCrystal_I2C lcd(0x27, 16, 2); // 全局变量 static char terrainUpper[TERRAIN_WIDTH + 1]; // 上层地形数组(多一位用于字符串结束符'\0') static char terrainLower[TERRAIN_WIDTH + 1]; // 下层地形数组 static bool buttonPushed = false; // 按键中断标志位关键点解析:
- 状态机:
HERO_POSITION_*这一系列宏定义,清晰地定义了小人所有可能的状态。游戏主循环 (loop) 的核心逻辑就是根据当前状态、输入(按键)和环境(地形),决定下一个状态是什么。这是处理复杂逻辑的经典方法。 - 双缓冲地形:
terrainUpper和terrainLower两个字符数组,分别对应LCD的上下两行。它们存储的不是直接的像素,而是代表“精灵索引”的字符。例如,SPRITE_TERRAIN_SOLID代表一个实心障碍物精灵。这种存储方式便于快速修改和渲染。 - I2C地址:
LiquidCrystal_I2C lcd(0x27, 16, 2);这里的0x27是I2C模块的地址。如果你的屏幕不亮,这是第一个要排查的地方。可以尝试将其改为0x3F。
3.2 自定义字符(精灵)创建
1602 LCD除了标准字符集,还允许用户自定义8个5x8像素的字符。我们的游戏画面就是由这些自定义字符拼凑而成的。
void initializeGraphics() { static byte graphics[] = { // 精灵0: 奔跑姿态1 (索引 SPRITE_RUN1=1) B01100, B01100, B00000, B01110, B11100, B01100, B11010, B10011, // 精灵1: 奔跑姿态2 (索引 SPRITE_RUN2=2) B01100, B01100, B00000, B01100, B01100, B01100, B01100, B01110, // ... 其余精灵定义(跳跃、地面等) }; int i; // 将字节数组载入LCD的CGRAM,创建自定义字符 // 注意:字符0通常保留,所以我们从索引1开始创建 for (i = 0; i < 7; ++i) { lcd.createChar(i + 1, &graphics[i * 8]); } // 初始化地形数组为空 for (i = 0; i < TERRAIN_WIDTH; ++i) { terrainUpper[i] = SPRITE_TERRAIN_EMPTY; terrainLower[i] = SPRITE_TERRAIN_EMPTY; } }每一组8个字节(如B01100)定义了一个5x8像素字符的一行(从左到右,高位在前)。B是Arduino的二进制字面量前缀。通过精心设计这些二进制值,我们画出了小人奔跑、跳跃以及障碍物的图案。lcd.createChar()函数将这些图案存入LCD的显存。
3.3 地形滚动与碰撞检测算法
这是游戏逻辑中最精妙的部分。地形如何平滑地向左移动?如何检测小人是否撞到了障碍物?
地形滚动 (advanceTerrain函数):
void advanceTerrain(char* terrain, byte newTerrain) { for (int i = 0; i < TERRAIN_WIDTH; ++i) { char current = terrain[i]; char next = (i == TERRAIN_WIDTH - 1) ? newTerrain : terrain[i + 1]; switch (current) { case SPRITE_TERRAIN_EMPTY: terrain[i] = (next == SPRITE_TERRAIN_SOLID) ? SPRITE_TERRAIN_SOLID_RIGHT : SPRITE_TERRAIN_EMPTY; break; case SPRITE_TERRAIN_SOLID: terrain[i] = (next == SPRITE_TERRAIN_EMPTY) ? SPRITE_TERRAIN_SOLID_LEFT : SPRITE_TERRAIN_SOLID; break; case SPRITE_TERRAIN_SOLID_RIGHT: terrain[i] = SPRITE_TERRAIN_SOLID; break; case SPRITE_TERRAIN_SOLID_LEFT: terrain[i] = SPRITE_TERRAIN_EMPTY; break; } } }这个函数实现了地形的“像素级”平滑滚动。它引入了一个中间状态:SPRITE_TERRAIN_SOLID_RIGHT和SPRITE_TERRAIN_SOLID_LEFT。想象一个实心方块向右移动,它的左边缘会先变成“左半部分”,右边缘会变成“右半部分”,然后才完全移出或移入。这个函数通过判断当前格子及其右侧格子的状态,来决定当前格子下一帧应该显示什么,从而在字符级别(5像素宽)上模拟了平滑移动的效果,避免了跳跃感。
碰撞检测 (drawHero函数核心部分):
bool drawHero(byte position, char* terrainUpper, char* terrainLower, unsigned int score) { bool collide = false; char upperSave = terrainUpper[HERO_HORIZONTAL_POSITION]; char lowerSave = terrainLower[HERO_HORIZONTAL_POSITION]; byte upper, lower; // ... 根据position状态,决定upper和lower应该显示什么精灵 ... if (upper != ' ') { terrainUpper[HERO_HORIZONTAL_POSITION] = upper; // 将小人精灵“画”到地形数组中 collide = (upperSave == SPRITE_TERRAIN_EMPTY) ? false : true; // 如果原来那个位置不是空的,就碰撞了! } if (lower != ' ') { terrainLower[HERO_HORIZONTAL_POSITION] = lower; collide |= (lowerSave == SPRITE_TERRAIN_EMPTY) ? false : true; } // ... 渲染地形数组到LCD ... // 最后,恢复地形数组该位置原来的值,因为小人只是“临时”画上去的 terrainUpper[HERO_HORIZONTAL_POSITION] = upperSave; terrainLower[HERO_HORIZONTAL_POSITION] = lowerSave; return collide; }碰撞检测的逻辑非常巧妙且高效。它没有去计算复杂的矩形重叠区域,而是利用了“地形数组”这个数据结构。在绘制小人之前,先保存小人即将绘制位置(HERO_HORIZONTAL_POSITION)上原有的地形元素(upperSave,lowerSave)。然后,把小人的精灵“写入”地形数组的对应位置。接着检查:如果原来保存的地形元素是“空”的,说明没撞上;如果不是空的(比如是障碍物精灵),那就发生了碰撞。检测完毕后,再把地形数组恢复原样。整个过程在内存中完成,速度极快。
3.4 主循环与状态迁移逻辑
loop()函数是游戏的大脑,它以大约10Hz(delay(100))的频率循环执行,驱动着整个游戏世界。
void loop() { static byte heroPos = HERO_POSITION_RUN_LOWER_1; static byte newTerrainType = TERRAIN_EMPTY; static byte newTerrainDuration = 1; static bool playing = false; static bool blink = false; static unsigned int distance = 0; if (!playing) { // 游戏未开始,显示“Press Start”并闪烁小人 drawHero((blink) ? HERO_POSITION_OFF : heroPos, terrainUpper, terrainLower, distance >> 3); // distance>>3 即 distance/8,作为分数显示 if (blink) { lcd.setCursor(0, 0); lcd.print("Press Start"); } delay(250); blink = !blink; if (buttonPushed) { // 等待按键开始游戏 initializeGraphics(); heroPos = HERO_POSITION_RUN_LOWER_1; playing = true; buttonPushed = false; distance = 0; } return; } // ---------- 游戏进行中 ---------- // 1. 地形向左滚动 advanceTerrain(terrainLower, newTerrainType == TERRAIN_LOWER_BLOCK ? SPRITE_TERRAIN_SOLID : SPRITE_TERRAIN_EMPTY); advanceTerrain(terrainUpper, newTerrainType == TERRAIN_UPPER_BLOCK ? SPRITE_TERRAIN_SOLID : SPRITE_TERRAIN_EMPTY); // 2. 随机生成新地形(控制游戏难度) if (--newTerrainDuration == 0) { if (newTerrainType == TERRAIN_EMPTY) { // 当前是空地,有1/3概率生成上方障碍,2/3概率生成下方障碍 newTerrainType = (random(3) == 0) ? TERRAIN_UPPER_BLOCK : TERRAIN_LOWER_BLOCK; newTerrainDuration = 2 + random(10); // 障碍物长度随机(2-11个单位) } else { // 当前是障碍,接下来生成一段空地 newTerrainType = TERRAIN_EMPTY; newTerrainDuration = 10 + random(10); // 空地长度随机(10-19个单位) } } // 3. 处理跳跃输入(中断标志位) if (buttonPushed) { // 只有在小人处于地面奔跑状态时才允许起跳 if (heroPos <= HERO_POSITION_RUN_LOWER_2) heroPos = HERO_POSITION_JUMP_1; buttonPushed = false; } // 4. 绘制小人并检测碰撞 if (drawHero(heroPos, terrainUpper, terrainLower, distance >> 3)) { playing = false; // 发生碰撞,游戏结束 } else { // 5. 更新小人状态(状态机核心) if (heroPos == HERO_POSITION_RUN_LOWER_2 || heroPos == HERO_POSITION_JUMP_8) { heroPos = HERO_POSITION_RUN_LOWER_1; // 完成一个奔跑循环或落地,回到奔跑姿态1 } else if ((heroPos >= HERO_POSITION_JUMP_3 && heroPos <= HERO_POSITION_JUMP_5) && terrainLower[HERO_HORIZONTAL_POSITION] != SPRITE_TERRAIN_EMPTY) { heroPos = HERO_POSITION_RUN_UPPER_1; // 跳跃到最高点时,如果下层有障碍,切换到上层奔跑 } else if (heroPos >= HERO_POSITION_RUN_UPPER_1 && terrainLower[HERO_HORIZONTAL_POSITION] == SPRITE_TERRAIN_EMPTY) { heroPos = HERO_POSITION_JUMP_5; // 在上层奔跑时,如果脚下空了(障碍结束),开始下坠 } else if (heroPos == HERO_POSITION_RUN_UPPER_2) { heroPos = HERO_POSITION_RUN_UPPER_1; // 上层奔跑动画循环 } else { ++heroPos; // 默认情况:状态向前推进一帧(如跳跃上升、下降过程) } ++distance; // 增加距离 } delay(100); // 控制游戏帧率,约10FPS }这段代码是游戏逻辑的集大成者。它清晰地分为了几个阶段:等待开始、地形生成与滚动、输入响应、碰撞检测、状态更新。状态迁移逻辑是其中最需要理解的部分,它通过一系列if-else条件,根据当前状态和地形环境,决定小人下一帧应该是什么状态,从而实现了奔跑、跳跃、上下层切换等所有动作。
4. 核心问题排查与优化技巧
在实际制作过程中,你几乎一定会遇到一些问题。下面是我总结的常见问题及其解决方法。
4.1 硬件连接与显示问题
| 问题现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
| LCD屏幕无任何显示,背光也不亮 | 电源未接通或接反 | 1. 检查5V和GND是否牢固连接至I2C模块。2. 确认线序正确, 5V接VCC,GND接GND。 |
| 背光亮,但屏幕显示白块或乱码 | I2C通信失败或对比度不对 | 1.首要步骤:调节I2C模块上的蓝色电位器,缓慢旋转直到字符清晰。 2. 检查 SDA(A4)和SCL(A5)线是否接对、接牢。3.修改I2C地址:将代码中 LiquidCrystal_I2C lcd(0x27,16,2);的0x27改为0x3F再试。 |
| 按键无反应 | 接线错误或内部上拉未启用 | 1. 确认按键一端接GND,另一端接D2。2. 在 setup()中,确认有pinMode(PIN_BUTTON, INPUT_PULLUP);语句。3. 用串口监视器打印 digitalRead(PIN_BUTTON)的值,按下时应从1变为0。 |
| 游戏画面闪烁、卡顿严重 | 帧率不稳定或内存不足 | 1. 检查loop()中的delay(100)是否被修改或删除。这是控制游戏速度的关键。2. 确保没有在循环内进行耗时的操作(如大量串口打印)。 3. Arduino Uno的SRAM只有2KB,确保没有定义过大的全局数组。 |
4.2 代码逻辑与功能调试
按键响应延迟或连跳:
- 原因:机械按键存在抖动。当你按下时,在几毫秒内电平会快速变化多次,可能被误判为多次按下。
- 解决方案:原代码使用了外部中断(
attachInterrupt) 并配合标志位 (buttonPushed) 来检测按键,这本身响应很快。但如果仍有问题,可以加入简单的软件消抖。在中断服务函数buttonPush()中或主循环检测标志位后,可以添加一个短暂的延时再读取引脚状态,但要注意中断服务函数应尽可能短。更优雅的做法是在主循环中,当buttonPushed被置位后,先延时10-50ms,再读取一次引脚状态确认。
游戏难度不合适:
- 调整地形生成:在
loop()函数中,找到生成新地形的random()函数调用。(random(3) == 0):控制上下障碍的比例。random(3)生成0,1,2,等于0的概率是1/3。增大分母(如random(5)==0)会使上方障碍更稀有。newTerrainDuration = 2 + random(10);:控制障碍的长度。2是最小长度,random(10)是随机增量。减小2或10会使障碍变短、更易跳过。newTerrainDuration = 10 + random(10);:控制空地的长度。减小这些值会使障碍更密集,游戏更难。
- 调整跳跃高度/速度:跳跃过程由
HERO_POSITION_JUMP_1到HERO_POSITION_JUMP_8这8个状态定义。你可以尝试减少中间状态(如跳过JUMP_4和JUMP_5),让跳跃更快;或者增加delay(100)的时间,让整个游戏变慢。
- 调整地形生成:在
想增加更多功能:
- 增加声音:连接一个无源蜂鸣器到另一个数字引脚(如D3)。在碰撞检测到
collide为true时,或者跳跃时,用tone()函数播放一个简短的音效。 - 记录最高分:利用Arduino的
EEPROM库。游戏结束时,比较当前分数distance >> 3与从EEPROM读取的历史最高分,如果更高则写入。在游戏开始画面显示最高分。 - 增加多种障碍:这需要修改自定义字符集,定义新的障碍精灵(如飞鸟、坑洞),并扩展
newTerrainType的类型和生成逻辑。
- 增加声音:连接一个无源蜂鸣器到另一个数字引脚(如D3)。在碰撞检测到
深度优化心得:原代码为了可读性,将分数显示放在了屏幕右上角。但
lcd.print(score)在分数位数变化时(如从99到100),需要先清除旧数字,比较耗时。一个优化技巧是:始终在固定位置(如第13-16列)打印4位数字,不足的前面补空格。例如:char scoreStr[5]; sprintf(scoreStr, “%4d”, score); lcd.print(scoreStr);。这样可以避免因字符数量变化引起的屏幕局部闪烁,让画面更稳定。
