基于Arduino Uno与1602 LCD的复古像素游戏开发实战
1. 项目概述:一个能“摸”到的复古像素游戏
几年前,我刚开始玩Arduino时,总想着用它做点有趣的东西,而不是仅仅点亮几个LED。后来我发现,用那块小小的1602 LCD屏幕,配合几个简单的按钮和传感器,就能复刻出童年掌机游戏的乐趣。今天要分享的,就是一个基于Arduino Uno的“跳跃方块”游戏。它的核心玩法很简单:屏幕底部有一个由字符拼成的“小人”,不断有方块从屏幕右侧向它移动,你需要看准时机按下按钮,让小人“跳”起来躲避方块。如果撞上了,旁边的红色LED会亮起,蜂鸣器也会发出“Game Over”的提示音,整个游戏的开关由一个电位器控制,转动它就像给这个微型游戏机“上发条”。
这个项目麻雀虽小,五脏俱全。它几乎涵盖了嵌入式系统开发中几个最核心的概念:微控制器编程、GPIO(通用输入输出)接口控制、以及硬件交互设计。通过Arduino游戏开发,你不仅能理解代码如何驱动硬件,更能直观地看到“按下按钮”这个动作,是如何通过一系列电信号转换,最终让屏幕上的像素点产生变化的。对于想入门硬件编程,又觉得单纯控制电机或传感器有些枯燥的朋友来说,制作一个能玩的小游戏,无疑是动力最强、成就感最高的学习路径。
2. 核心硬件选型与电路设计思路
2.1 主控与显示模块:为什么是Arduino Uno和1602 LCD?
选择Arduino Uno作为大脑,几乎是所有入门项目的共识。它基于ATmega328P微控制器,有14个数字I/O口和6个模拟输入口,对于本项目所需的资源(控制LCD、读取按钮和电位器、驱动LED和蜂鸣器)绰绰有余。更重要的是,其庞大的社区和丰富的库支持,能让你在遇到问题时快速找到解决方案。例如,驱动1602 LCD,我们直接使用Arduino IDE内置的LiquidCrystal库,几行代码就能完成初始化,无需深究底层时序。
1602 LCD显示屏模块(16x2字符)是这个游戏的“舞台”。它之所以经典,是因为其接口标准、价格低廉且易于驱动。所谓16x2,意味着可以显示2行,每行16个字符。请注意,它显示的是字符,而非像素点。这意味着我们的游戏角色“小人”和“方块”,都需要用自定义字符(Custom Character)来设计。LiquidCrystal库允许我们定义最多8个5x8像素的自定义字符,这正好为我们创造了绘制简单图形的基础。相比于更复杂的图形点阵屏,1602 LCD在编程上更简单,更能让我们专注于游戏逻辑本身。
2.2 输入与反馈设备:构建游戏的交互闭环
一个完整的交互循环需要输入、处理和输出。在本项目中,我们通过以下设备构建了这个循环:
- 电位器(模拟输入):这里它被创新性地用作“游戏开关”。电位器本质上是一个可变电阻。Arduino的模拟输入引脚(A0-A5)可以读取其分压后的电压值(0-5V),并映射为0-1023的整数。我们的逻辑是:当读取到的值超过某个阈值(例如512),则认为游戏开启;低于阈值,则关闭。这比使用一个额外的开关按钮更节省I/O口,也增加了操作的“仪式感”。
- 轻触按钮(数字输入):作为唯一的动作按键,控制“跳跃”。这里涉及按键消抖这个关键概念。机械按钮在按下和弹起的瞬间,内部的金属触点会发生物理抖动,导致Arduino会在极短时间内读取到多次高低电平变化,误判为多次按下。解决方法通常是在代码中加入一个短暂的延时(如10-50毫秒),等待抖动过去后再确认按键状态。
- LED与蜂鸣器(输出反馈):它们是游戏的“感官”延伸。当碰撞发生时,红色LED亮起提供视觉警示,无源蜂鸣器发出特定频率的声音提供听觉警示。这种多模态反馈能极大增强游戏的沉浸感。驱动LED需要串联一个约220-330欧姆的限流电阻,防止电流过大烧毁LED或单片机引脚。驱动蜂鸣器则通常使用PWM(脉冲宽度调制)引脚来产生不同频率的方波,从而发出不同音调。
注意:关于无源蜂鸣器与有源蜂鸣器:本项目更适合使用无源蜂鸣器。有源蜂鸣器内部自带振荡电路,通电即响,只能发出固定频率的声音。而无源蜂鸣器需要外部提供PWM信号才能发声,我们可以通过代码精确控制其频率和时长,从而播放简单的音调甚至旋律,为游戏失败、跳跃成功等事件配上不同的音效,体验更佳。
2.3 电路连接详解与原理图解读
搭建电路是硬件项目中最需要耐心的一环。一个清晰的接线图胜过千言万语,但理解其背后的原理同样重要。以下是核心连接逻辑的解析:
LCD模块的连接(采用4位数据模式): 1602 LCD通常有16个引脚,但我们无需使用全部。为了节省I/O口,我们采用4位数据模式,只使用DB4-DB7这4根数据线来传输数据。
- VSS (Pin 1): 接地(GND)。
- VDD (Pin 2): 接+5V。
- VO (Pin 3): 液晶对比度调节。接一个10K电位器的中间抽头,电位器两端分别接+5V和GND。通过调节此电压(0-5V)来改变屏幕显示的深浅。
- RS (Pin 4): 寄存器选择。接数字引脚(如12)。高电平时选择数据寄存器(发送要显示的数据),低电平时选择指令寄存器(发送命令)。
- RW (Pin 5): 读写控制。直接接地(GND),因为我们只向LCD写数据,不读取。
- E (Pin 6): 使能信号。接数字引脚(如11)。在数据线上数据稳定后,需要一个高脉冲来锁存数据。
- DB4-DB7 (Pin 11-14): 4位数据线。分别接数字引脚(如5, 4, 3, 2)。
- A (Pin 15) / K (Pin 16): 背光电源正极和负极。如果LCD带背光,将A通过一个限流电阻(如220Ω)接+5V,K接地以开启背光。
其他元件连接:
- 按钮:一端接+5V,另一端接一个10KΩ的下拉电阻到GND,同时连接到Arduino的一个数字输入引脚(如8)。当按钮未按下时,输入引脚通过下拉电阻被稳定拉低(LOW);按下时,被拉高(HIGH)。
- 电位器:两侧引脚分别接+5V和GND,中间抽头接模拟输入引脚A0。
- LED:正极(长脚)通过一个220Ω电阻连接到数字引脚(如9),负极接GND。
- 无源蜂鸣器:正极连接到支持PWM的数字引脚(如10),负极接GND。
3. 游戏软件逻辑深度剖析与代码实现
3.1 核心状态机与游戏循环设计
任何游戏的核心都是一个高速运行的循环(Game Loop),在Arduino中就是loop()函数。在这个循环里,我们需要按顺序处理几件事:读取输入、更新游戏状态、渲染画面。对于“跳跃方块”这类简单游戏,使用状态机(State Machine)模型来管理游戏状态非常清晰。
我们可以定义几个游戏状态:
GAME_OFF: 游戏关闭状态(电位器未开启)。屏幕显示待机信息。GAME_STANDBY: 游戏就绪状态(电位器已开启,等待按下开始键)。屏幕显示“Press to Start”。GAME_PLAYING: 游戏进行中。在此状态下执行核心游戏逻辑。GAME_OVER: 游戏结束状态(发生碰撞)。触发LED和蜂鸣器,屏幕显示分数,等待重启。
在loop()中,我们首先读取电位器值判断总开关,然后根据当前状态执行相应的函数。这种设计将复杂的逻辑分解到不同状态中处理,代码结构清晰,易于调试和扩展。
3.2 自定义字符设计与画面渲染
1602 LCD的每个字符位置是一个5x8的点阵。LiquidCrystal库的createChar()函数允许我们定义自己的图形。我们需要至少定义两个自定义字符:一个代表“小人”,一个代表“方块”。
例如,小人可以设计成一个向上的箭头或简单的人形:
// 示例:一个简单的小人字符 (5x8像素) byte playerChar[8] = { B00100, // * B01110, // *** B00100, // * B11111, // ***** B10101, // * * * B00100, // * B01010, // * * B10001 // * * }; lcd.createChar(0, playerChar); // 将数组绑定到0号自定义字符在渲染时,我们只需要在屏幕特定位置(第二行,某个固定列)打印这个自定义字符即可:lcd.write(byte(0))。
方块的移动,实质上是每隔一定时间(游戏速度),在屏幕第二行(地面行)将方块字符从右向左移动一列。这可以通过一个数组来记录第二行每个位置是空格还是方块,然后在每一帧重新绘制整个第二行来实现。
3.3 碰撞检测、物理与分数系统
碰撞检测在这个游戏里极其简单:只需要判断“小人”所在的列位置,与当前所有“方块”所在的列位置是否重合。如果重合,且小人处于“未跳跃”状态(即在地面),则判定为碰撞,游戏状态切换至GAME_OVER。
跳跃物理用一个简单的变量模拟即可。例如,定义一个jumpHeight变量和isJumping状态。按下按钮时,如果小人在地面,则进入isJumping状态,jumpHeight设为2(表示可以向上跳两行)。在游戏更新逻辑中,如果处于跳跃状态,则每帧将小人绘制在第一行(空中),并递减jumpHeight。当jumpHeight减到0时,开始下落,直到回到地面行。
分数系统:每成功躲避一个方块(即方块移出屏幕最左侧且未发生碰撞),分数加一。游戏速度可以随着分数增加而逐渐加快,通过减少方块移动的帧间隔来实现,增加游戏挑战性。
3.4 完整代码框架与关键函数解析
以下是精简后的核心代码框架,展示了主要逻辑结构:
#include <LiquidCrystal.h> // 引脚定义 const int rs = 12, en = 11, d4 = 5, d5 = 4, d6 = 3, d7 = 2; const int buttonPin = 8; const int potPin = A0; const int ledPin = 9; const int buzzerPin = 10; LiquidCrystal lcd(rs, en, d4, d5, d6, d7); // 游戏变量 enum GameState { GAME_OFF, GAME_STANDBY, GAME_PLAYING, GAME_OVER }; GameState state = GAME_OFF; int score = 0; int gameSpeed = 500; // 初始速度,毫秒 bool isJumping = false; int jumpCounter = 0; int playerPos = 0; // 小人在第二行的列位置 int obstaclePos = 15; // 方块起始位置(屏幕最右) // 自定义字符 byte playerChar[8] = {...}; byte blockChar[8] = {...}; void setup() { pinMode(buttonPin, INPUT); pinMode(ledPin, OUTPUT); pinMode(buzzerPin, OUTPUT); lcd.begin(16, 2); lcd.createChar(0, playerChar); lcd.createChar(1, blockChar); lcd.print("Jump Block Game"); } void loop() { int potValue = analogRead(potPin); bool buttonPressed = digitalRead(buttonPin); // 状态机调度 switch(state) { case GAME_OFF: if(potValue > 512) { state = GAME_STANDBY; lcd.clear(); } break; case GAME_STANDBY: lcd.setCursor(0,0); lcd.print("Press to Start"); if(buttonPressed) { state = GAME_PLAYING; gameInit(); // 初始化游戏变量 lcd.clear(); } break; case GAME_PLAYING: updateGame(buttonPressed); // 更新游戏逻辑 drawGame(); // 绘制画面 delay(gameSpeed); // 控制游戏速度 break; case GAME_OVER: gameOverSequence(); // 失败提示 if(buttonPressed) { state = GAME_STANDBY; resetFeedback(); } break; } } void updateGame(bool btn) { // 处理跳跃 if(btn && !isJumping && jumpCounter == 0) { isJumping = true; jumpCounter = 2; // 跳跃高度 tone(buzzerPin, 523, 100); // 跳跃音效(Do) } if(isJumping) { jumpCounter--; if(jumpCounter <= 0) isJumping = false; } // 移动方块 obstaclePos--; if(obstaclePos < 0) { obstaclePos = 15; score++; gameSpeed = max(100, gameSpeed - 10); // 速度加快,设置下限 } // 碰撞检测 if(obstaclePos == playerPos && !isJumping) { state = GAME_OVER; } } void drawGame() { lcd.setCursor(0,0); lcd.print("Score:"); lcd.print(score); lcd.setCursor(0,1); // 绘制地面行 for(int i=0; i<16; i++) { if(i == playerPos) { lcd.write(byte(0)); // 绘制小人 } else if(i == obstaclePos) { lcd.write(byte(1)); // 绘制方块 } else { lcd.print(" "); } } } void gameOverSequence() { digitalWrite(ledPin, HIGH); tone(buzzerPin, 262, 200); // 失败低音(Do) delay(200); noTone(buzzerPin); delay(200); lcd.setCursor(0,0); lcd.print("Game Over! Score:"); lcd.setCursor(0,1); lcd.print(score); }4. 系统调试、优化与功能扩展实战
4.1 硬件调试常见问题与排查
LCD屏幕不亮或显示乱码:
- 检查电源和背光:确认VDD和背光引脚(A/K)接线正确,特别是背光是否接了限流电阻。
- 调整对比度:这是最常见的问题。缓慢调节接在VO引脚上的电位器,直到字符清晰显示。有时对比度电压不合适,屏幕看似没显示,其实已有内容。
- 检查数据线连接:确认RS、E、DB4-DB7的引脚号在代码和实际连接中完全一致。接触不良会导致乱码。
- 初始化延时:在
setup()的lcd.begin()后,加一个短暂的delay(500),给LCD模块足够的启动时间。
按钮响应不灵或连跳:
- 消抖处理:确保代码中实现了按键消抖。最简方法是在检测到按键按下后,
delay(50)再读取一次状态确认。 - 上拉/下拉电阻:确认使用了正确的电阻。如果使用了下拉电阻(按钮接高电平),代码中应检测
HIGH为按下;如果启用了Arduino内部上拉电阻(pinMode(pin, INPUT_PULLUP)),则按钮应接GND,检测LOW为按下。两者不要混用。
- 消抖处理:确保代码中实现了按键消抖。最简方法是在检测到按键按下后,
蜂鸣器不响或常响:
- 区分有源/无源:确认你使用的是无源蜂鸣器。有源蜂鸣器接PWM引脚可能会响,但无法控制音调。
- 检查
tone()函数:tone(pin, frequency, duration)函数用于驱动无源蜂鸣器。确保引脚号、频率(单位Hz)正确。播放后如果需要停止,使用noTone(pin)。 - 驱动能力:如果声音微弱,可以尝试在蜂鸣器正极和Arduino引脚之间加一个NPN三极管(如8050)进行电流放大。
4.2 软件优化与体验提升技巧
- 非阻塞式延时:游戏主循环中的
delay(gameSpeed)会阻塞所有其他操作,导致按键响应在延时期间不灵敏。更好的方法是使用millis()函数实现非阻塞定时。例如,记录上次更新游戏逻辑的时间戳,当millis() - lastUpdateTime > gameSpeed时,才执行方块移动和画面更新,这样按键检测可以持续快速响应。unsigned long previousGameUpdate = 0; void loop() { // ... 读取输入等 if (state == GAME_PLAYING) { unsigned long currentMillis = millis(); if (currentMillis - previousGameUpdate >= gameSpeed) { previousGameUpdate = currentMillis; updateGame(buttonPressed); drawGame(); } } // ... 其他状态处理 } - 更平滑的跳跃动画:当前的跳跃是“瞬移”到空中。可以引入“起跳”和“下落”两个中间状态字符,让动画更细腻。
- 随机性增强:让方块的初始出现位置有一定随机性,或者随机生成不同高度的障碍(需要更多自定义字符),可以大大增加游戏的可玩性。使用
random(min, max)函数来实现。
4.3 项目功能扩展思路
这个基础框架有巨大的扩展潜力:
- 增加多种障碍物:定义多个自定义字符,如高方块、矮方块、坑等,随机生成,需要不同的跳跃时机或下蹲(设计一个下蹲字符)来躲避。
- 引入“金币”收集:在屏幕上随机出现代表金币的字符,跳跃碰到可以加分。
- 使用外部中断优化按键:将跳跃按钮接到Arduino的中断引脚(如Uno的2或3号引脚),使用
attachInterrupt()函数。这样无论主循环在执行什么,按下按钮都能立即响应,实现零延迟跳跃,手感更佳。 - 添加声音模块:使用DFPlayer Mini等MP3模块,在游戏开始、结束、得分时播放真实的音效或背景音乐,取代简单的蜂鸣器单音。
- 更换显示设备:升级为OLED显示屏(I2C接口),可以显示更细腻的像素图形,游戏画面将得到质的飞跃。驱动原理类似,只是换用
Adafruit_SSD1306等库。
5. 从原型到作品的进阶思考
完成这个项目后,你收获的不仅仅是一个能玩的小游戏。你实践了嵌入式开发从需求分析、硬件选型、电路搭建、代码编写到调试优化的完整流程。LCD显示屏作为人机交互界面,电位器作为模拟输入设备,按钮作为数字输入设备,LED和蜂鸣器作为输出反馈设备,它们共同构成了一个典型的微型嵌入式系统。
这个项目的代码和电路虽然简单,但其架构——状态机管理、非阻塞编程、自定义图形渲染、简单的物理与碰撞系统——是许多复杂项目的缩影。当你下次用Arduino做一个智能温室控制器时,你会发现状态机依然好用;当你用ESP32做一个网络天气站时,非阻塞的思想能保证网络请求不卡住界面更新。
硬件项目的魅力在于软硬结合带来的实在感。屏幕上跳动的方块,指尖按下按钮的触感,失败时蜂鸣器发出的声响,所有这些共同构成了一次完整的创造体验。我建议你在实现基础功能后,一定要尝试至少一项扩展功能。无论是改一个更酷的角色造型,还是增加一个积分排行榜(需要用到EEPROM存储),这个过程里遇到的挑战和解决问题的过程,才是学习嵌入式开发最宝贵的部分。动手去试,代码烧录进去,电路接上,看看会发生什么。所有的不确定,都会在硬件通电的那一刻,得到最确定的答案。
