ESP32单按钮复古游戏机开发:硬件选型、状态机设计与交互实现
1. 项目概述与核心思路
几年前我在整理旧物时,翻出了一台老旧的Game Boy,那种纯粹的、由几个像素点和简单旋律构成的快乐,让我萌生了一个想法:能不能用现在唾手可得的开源硬件,复刻那种极简的复古游戏体验,并且把交互做到极致简单?于是,“Button Freak”这个项目诞生了。它的核心目标非常明确:用一块ESP32开发板、一块小屏幕和一个物理按钮,打造一个能玩经典游戏的便携式设备。这不仅仅是一个怀旧玩具,更是一个探索如何在极度有限的硬件资源(特别是输入方式)下,设计出流畅交互和完整游戏逻辑的嵌入式开发实践。
这个项目的技术价值在于,它巧妙地平衡了“功能”与“约束”。ESP32提供了足够的算力来驱动320x240的IPS屏幕和运行游戏逻辑,但我们将输入通道压缩到了仅一个按钮。这意味着所有的菜单导航、游戏控制(比如贪吃蛇的转向、弹球游戏的挡板移动)都必须通过这一颗按钮的不同按法(如短按、长按、连击)来映射。这迫使开发者必须深入思考状态机设计、用户交互时序和代码的效率,是学习嵌入式系统事件驱动编程和资源管理的绝佳案例。对于刚接触微控制器的朋友,你能学到如何驱动显示屏、处理GPIO输入、管理内存中的图像数据;对于有经验的开发者,这是一个优化交互逻辑和代码结构的有趣挑战。
2. 硬件选型与电路设计解析
2.1 核心控制器:为什么是ESP32 FireBeetle C6?
市面上ESP32的开发板很多,我最终选择了DFRobot的FireBeetle ESP32 C6 Mini。这个选择背后有几个关键的考量。首先,ESP32-C6芯片本身支持Wi-Fi 6和蓝牙5.0,虽然我们这个游戏机项目可能暂时用不到无线功能,但它意味着板子拥有更现代的射频性能和更低的功耗,为未来可能的OTA升级或分数上传功能留出了空间。其次,FireBeetle系列以紧凑的尺寸和良好的电源管理著称。C6 Mini板载了锂电池充电管理芯片,可以直接连接一块3.7V的锂电池,并通过板载的DC-DC电路提供稳定的3.3V输出,这对于一个追求便携性的设备来说是刚需,你不需要再外接复杂的电源模块。
更重要的是,它的GPIO引脚布局与常见的ESP32开发板兼容,但体积更小。在有限的游戏机外壳空间内,每一毫米都很宝贵。当然,如果你手头只有ESP32-S3或经典的ESP32-DevKitC,也完全没问题,本项目代码具有很好的移植性,你只需要在Arduino IDE中正确选择开发板型号,并根据引脚定义调整接线即可。
2.2 显示单元:2.0英寸IPS TFT屏幕的考量
显示部分我使用了DFRobot的Fermion 2.0英寸320x240 IPS TFT屏。选择它,分辨率是关键。320x240(QVGA)是一个甜点级的分辨率:对于复古游戏的像素风格来说足够清晰,能够细腻地显示贪吃蛇的每一节身体和弹球的运动轨迹;同时,它又不会对ESP32的RAM和刷新率造成过大压力。IPS面板提供了远超旧式TN屏的可视角度和色彩表现,让游戏体验更舒适。
这块屏幕通常使用SPI(串行外设接口)协议与主控通信。SPI的优势是速度快、接线简单(仅需4-5根数据线+电源线),非常适合驱动这种中小尺寸的显示屏。在购买时,务必确认屏幕驱动芯片型号(常见如ST7789、ILI9341),并确保你所用的显示库(如本项目用的DFRobot_GDL)支持该芯片。接线时,除了SCK(时钟)、MOSI(主出从入)、MISO(主入从出,部分屏可省略)这几根数据线,还需要连接DC(数据/命令选择)、RST(复位)和CS(片选)引脚。具体的引脚连接需要根据你的ESP32开发板可用引脚和屏幕接口来定义,后面会给出一个参考接线表。
2.3 交互与反馈:单按钮与蜂鸣器的设计
整个项目的交互精髓都凝结在这颗按钮上。我选用了一颗常见的12mm卧式带帽轻触开关。它的手感清晰,行程适中,是关键输入设备可靠性的保证。在电路设计上,我们采用最简单的上拉电阻接法:按钮一端接地(GND),另一端连接ESP32的某个GPIO引脚(如GPIO 0),同时该引脚通过一个10kΩ的电阻上拉到3.3V。这样,当按钮未按下时,引脚读到的是高电平(3.3V);按下时,引脚直接接地,读到低电平(0V)。ESP32的内部上拉电阻也可以启用,但外接一个物理电阻通常更稳定可靠。
蜂鸣器则用于提供游戏的音效反馈,比如贪吃蛇吃到食物时的“嘀”声,或者游戏结束的提示音。我选择了一个有源蜂鸣器(内部自带振荡电路),因为它驱动简单,只需要给一个高电平信号就会持续发声,给低电平就停止。将其正极(+)连接到一个GPIO引脚,负极(-)连接GND即可。需要注意的是,有源蜂鸣器工作电流可能达到几十毫安,最好通过一个三极管或MOSFET来驱动,以保护ESP32的GPIO引脚。如果使用无源蜂鸣器,则需要通过PWM(脉冲宽度调制)信号来产生不同频率的声音,可玩性更高但代码稍复杂。
2.4 整体供电与电路连接方案
一个稳定的电源是系统可靠工作的基石。方案有两种:一是通过USB Type-C接口直接供电并同时为内置锂电池充电;二是完全依赖电池供电。FireBeetle ESP32 C6 Mini的VBAT引脚可以直接连接锂电池正极,系统会自动管理充放电。以下是整个系统的接线参考表示例(请根据你的实际板卡引脚调整):
| 组件 | 连接至 ESP32 引脚 | 功能说明 | 注意事项 |
|---|---|---|---|
| TFT屏幕 SCK | GPIO 18 | SPI时钟线 | 需在代码中配置 |
| TFT屏幕 MOSI | GPIO 23 | SPI数据输出线 | 需在代码中配置 |
| TFT屏幕 DC | GPIO 2 | 数据/命令选择 | 可自定义,需与代码匹配 |
| TFT屏幕 RST | GPIO 4 | 硬件复位 | 可接,也可用软件复位 |
| TFT屏幕 CS | GPIO 5 | 片选信号 | 低电平有效 |
| TFT屏幕 VCC | 3.3V | 电源正极 | 确保屏幕逻辑电压是3.3V |
| TFT屏幕 GND | GND | 电源地 | 共地 |
| 按钮一端 | GPIO 0 | 输入信号 | 另一端接GND,引脚配置为上拉输入 |
| 蜂鸣器正极 | GPIO 15 | 输出控制 | 建议通过三极管驱动 |
| 蜂鸣器负极 | GND | 电源地 | |
| 锂电池正极 | VBAT | 电池供电输入 | FireBeetle专属引脚 |
| 锂电池负极 | GND | 电池地 |
注意 :GPIO0在ESP32上常用于引导模式选择,上电时如果被拉低可能会进入下载模式。因此,在硬件设计时,要确保按钮按下(拉低GPIO0)不会发生在系统刚通电的瞬间。或者,你可以换用一个非功能复用的GPIO引脚,如GPIO13、GPIO14等。
3. 软件开发环境搭建与核心库详解
3.1 Arduino IDE配置与ESP32板卡支持
虽然PlatformIO或VS Code with PlatformIO是更专业的嵌入式开发选择,但Arduino IDE以其极低的上手门槛和丰富的库生态,依然是快速原型开发的首选。首先,去Arduino官网下载并安装最新版的IDE。安装完成后,打开“文件”->“首选项”,在“附加开发板管理器网址”中输入以下URL: https://espressif.github.io/arduino-esp32/package_esp32_index.json 。这个链接包含了乐鑫官方维护的ESP32系列开发板支持包。
接着,打开“工具”->“开发板”->“开发板管理器”,搜索“esp32”。你应该能看到由“Espressif Systems”提供的“esp32”平台,点击安装。安装过程可能需要一些时间,因为它会下载所有必要的编译工具链和核心库。安装完成后,在“工具”->“开发板”列表中,就能找到“FireBeetle ESP32-C6”或类似的选项。选择它,并根据你的实际情况选择正确的端口(COMxx或/dev/ttyUSBx)。
3.2 DFRobot_GDL显示库的安装与特性
本项目使用DFRobot_GDL库来驱动屏幕。这是一个由DFRobot维护的、针对其自家显示屏优化的图形库,但它也兼容许多基于ST7789、ILI9341等常见驱动芯片的屏幕。它的优势在于API相对简洁,且针对ESP32等32位MCU进行了性能优化。
在Arduino IDE中,点击“项目”->“加载库”->“管理库…”,打开库管理器。在搜索框中输入“DFRobot_GDL”,找到后点击安装。这个库封装了底层的SPI通信和基本的绘图函数(如画点、画线、填充矩形、显示文字和图片)。相比于更通用的TFT_eSPI库,DFRobot_GDL在初始化配置上可能更简单,但功能同样强大,足以满足我们游戏开发的需求。
3.3 图像资源处理:从PNG到C语言字节数组
复古游戏机怎么能少了精致的像素图标?我们的菜单里需要为“Snake”和“Bouncing Ball”游戏各准备一个图标。这里涉及一个嵌入式开发中的常见操作:将图片资源转换为C语言代码中可以直接使用的字节数组(Byte Array)。
首先,你需要准备两张32x32像素的PNG图片。可以使用任何图像编辑软件(如Photoshop、GIMP,甚至是在线的像素画工具)来绘制。风格最好是黑白色或有限的几种颜色,以符合复古气质,并且减少数据量。
然后,我们使用一个非常实用的在线工具: Image2cpp (访问地址可通过搜索获得)。打开网站后:
- 点击“Choose File”上传你的PNG图标。
- 在“Canvas size”中确保宽度和高度都是32。
- “Scanning method”选择“Vertical (1 bit per pixel)”或根据你的颜色深度选择。对于单色图标,1位每像素(黑白)就够了。
- “Code output format”选择“Arduino Code”。
- “Draw mode”选择“Vertical - 1 bit per pixel”。
- 最关键的是“Output”部分,它会实时生成一个
const unsigned char数组。你可以给数组起个名字,比如snake_icon。 - 点击“Generate code”,工具就会生成一段完整的C/C++代码,里面包含了你的图片数据。
将生成的代码复制到你的Arduino项目文件中,通常放在一个头文件(如 icons.h )里,或者直接放在主程序文件的开头。这样,在程序中就可以通过数组名来引用这张图片,并使用显示库的 drawBitmap() 或类似函数将其绘制到屏幕上。
实操心得 :在Image2cpp中,务必注意“色彩”和“亮度反转”选项。有些屏幕驱动是低电平点亮像素,有些是高电平。如果图标显示出来是反色的,可以勾选“Invert image colors”重新生成代码。多试几次,直到在屏幕上预览正确。
4. 系统软件架构与状态机设计
4.1 单按钮交互的逻辑抽象
只有一个按钮,却要完成“上下选择菜单”、“确认进入游戏”、“游戏中控制”等多个功能,这需要一套严谨的状态机(State Machine)来管理。状态机是嵌入式系统处理复杂逻辑的利器,它将程序运行划分为若干个离散的状态,每个状态下对相同的输入(按钮事件)做出不同的响应。
我们可以定义几个核心状态:
STATE_MAIN_MENU:主菜单状态,显示游戏列表和图标。STATE_GAME_SNAKE:运行贪吃蛇游戏的状态。STATE_GAME_BOUNCE:运行弹球游戏的状态。STATE_PAUSE或STATE_GAME_OVER:游戏暂停或结束状态。
在 STATE_MAIN_MENU 下,按钮的短按(按下并快速释放)可以用于在“Snake”和“Bouncing Ball”两个选项间循环高亮选择。按钮的长按(按下并保持超过某个阈值,如800毫秒)则可以被解释为“确认”,进入当前高亮的游戏状态。
4.2 主循环与事件驱动编程模型
Arduino程序的核心是 setup() 和 loop() 函数。在 setup() 中,我们初始化屏幕、设置按钮引脚为输入模式(启用内部上拉)、初始化蜂鸣器引脚为输出,并加载初始界面。
loop() 函数则是一个永不停止的循环。我们不能在里面使用 delay() 来等待按钮按下,因为这会阻塞整个程序,导致动画卡顿、游戏逻辑停滞。正确的做法是采用 非阻塞式 的事件驱动编程。在每次 loop() 循环中,我们做以下几件事:
- 读取按钮状态 :调用一个
readButton()函数,该函数会判断按钮是“未按下”、“短按”还是“长按”,并返回一个事件类型。 - 更新游戏状态 :根据当前程序状态(
currentState)和接收到的事件,调用相应的处理函数。例如,在STATE_GAME_SNAKE下,收到“短按”事件可能意味着让蛇转向。 - 渲染显示 :根据当前状态更新屏幕内容。菜单就画菜单,游戏就画游戏画面。
- 处理游戏逻辑 :例如,移动贪吃蛇、计算弹球位置等。这些逻辑更新应该基于时间间隔(使用
millis()函数计时),而不是依赖于循环速度。
这种模式确保了系统响应迅速,动画流畅,是开发交互式嵌入式应用的黄金准则。
4.3 游戏逻辑与显示渲染的分离
为了代码清晰和易于维护,最好将游戏逻辑与显示渲染部分分离。例如,可以设计一个 SnakeGame 类和一个 BounceGame 类。每个类内部维护自己的游戏数据(如蛇的坐标数组、食物的位置、球的速度、挡板位置等),并提供诸如 update() (更新一帧逻辑)、 handleInput() (处理输入)、 draw() (绘制到屏幕)等公共方法。
在主 loop() 中,根据 currentState ,调用对应游戏对象的 update() 和 draw() 方法。显示部分则通过一个全局的屏幕驱动对象来统一管理。这样,如果你想修改贪吃蛇的移动速度,只需修改 SnakeGame::update() 里的逻辑;想改变渲染颜色,只需修改 draw() 方法中的绘图函数调用。高内聚、低耦合的设计让代码更健壮,也方便未来扩展第三个、第四个游戏。
5. 核心代码实现与分步解析
5.1 硬件初始化与引脚配置
一切从正确的初始化开始。在 setup() 函数中,我们需要有条不紊地建立与所有硬件的通信。
#include <DFRobot_GDL.h>
// 假设使用ST7789驱动芯片,屏幕分辨率320x240
#define TFT_DC 2
#define TFT_CS 5
#define TFT_RST 4
// 使用硬件SPI,SCK=18, MOSI=23在ESP32上通常是默认的硬件SPI引脚
DFRobot_GDL display(TFT_DC, TFT_CS, TFT_RST);
#define BUTTON_PIN 0
#define BUZZER_PIN 15
// 按钮状态跟踪变量
bool lastButtonState = HIGH; // 上拉电阻,初始为高
bool buttonState = HIGH;
unsigned long lastDebounceTime = 0;
unsigned long debounceDelay = 50; // 消抖延时
// 游戏状态
enum GameState { MAIN_MENU, PLAYING_SNAKE, PLAYING_BOUNCE, GAME_OVER };
GameState currentState = MAIN_MENU;
void setup() {
Serial.begin(115200);
// 初始化显示屏
display.begin();
display.fillScreen(COLOR_RGB565_BLACK); // 清屏为黑色
display.setTextColor(COLOR_RGB565_WHITE);
display.setTextSize(2);
// 初始化按钮引脚为上拉输入模式
pinMode(BUTTON_PIN, INPUT_PULLUP);
// 初始化蜂鸣器引脚为输出模式
pinMode(BUZZER_PIN, OUTPUT);
digitalWrite(BUZZER_PIN, LOW); // 初始静音
// 绘制初始主菜单
drawMainMenu();
// 播放启动提示音
playStartTone();
}
注意事项 :引脚编号(如
GPIO0)在Arduino环境中有时直接写数字0即可,但为了清晰,可以使用预定义宏。INPUT_PULLUP启用了内部上拉电阻,这样外部电路就可以省去一个物理上拉电阻,但如之前所述,在关键应用中外加上拉电阻更可靠。消抖延时debounceDelay的值需要根据按钮特性调整,通常10-50毫秒是合理的。
5.2 按钮消抖与事件识别函数
机械按钮在按下和释放的瞬间会产生一段时间的电平抖动,如果不处理,会被误判为多次按下。我们需要一个消抖函数来获取稳定的按钮状态,并进一步识别出“短按”和“长按”事件。
// 定义一个按钮事件枚举
enum ButtonEvent { NO_EVENT, SHORT_PRESS, LONG_PRESS };
ButtonEvent readButtonEvent() {
ButtonEvent event = NO_EVENT;
static bool buttonActive = false;
static unsigned long buttonPressTime = 0;
const unsigned long longPressThreshold = 800; // 长按判定阈值,800毫秒
// 读取当前物理引脚状态
bool reading = digitalRead(BUTTON_PIN);
// 消抖逻辑:如果读数与上次稳定状态不同,则重置消抖计时器
if (reading != lastButtonState) {
lastDebounceTime = millis();
}
// 如果经过消抖延时后,状态确实改变了
if ((millis() - lastDebounceTime) > debounceDelay) {
if (reading != buttonState) {
buttonState = reading;
// 按钮状态变为低电平(按下)
if (buttonState == LOW) {
buttonPressTime = millis();
buttonActive = true;
}
// 按钮状态变为高电平(释放)
else {
if (buttonActive) {
// 计算按下持续时间
unsigned long pressDuration = millis() - buttonPressTime;
if (pressDuration < longPressThreshold) {
event = SHORT_PRESS; // 短按
}
// 注意:长按事件是在释放时判断的。另一种设计是在按下达到阈值时立即触发。
buttonActive = false;
}
}
}
}
// 检查是否正在长按(达到阈值但还未释放)
if (buttonActive && (millis() - buttonPressTime >= longPressThreshold)) {
event = LONG_PRESS;
buttonActive = false; // 触发长按后,重置状态,避免重复触发
// 或者不重置,以实现“长按持续触发”的效果,根据游戏设计决定。
}
lastButtonState = reading;
return event;
}
这个函数是交互的核心。它返回 NO_EVENT 、 SHORT_PRESS 或 LONG_PRESS 。在主循环中,我们根据返回的事件和当前状态来决定做什么。
5.3 主菜单与游戏状态绘制函数
屏幕绘制是用户体验的直接体现。我们需要编写清晰的绘制函数。
int menuSelection = 0; // 0: Snake, 1: Bounce
const char* gameNames[] = {"Snake", "Bouncing Ball"};
void drawMainMenu() {
display.fillScreen(COLOR_RGB565_BLACK);
display.setCursor(40, 20);
display.setTextSize(3);
display.print("BUTTON FREAK");
display.setTextSize(2);
for (int i = 0; i < 2; i++) {
int yPos = 80 + i * 50;
if (i == menuSelection) {
// 高亮选中的项目
display.fillRect(30, yPos - 10, 260, 30, COLOR_RGB565_BLUE);
display.setTextColor(COLOR_RGB565_WHITE);
} else {
display.setTextColor(COLOR_RGB565_GRAY);
}
display.setCursor(50, yPos);
display.print(gameNames[i]);
// 这里可以调用 drawBitmap 显示对应的图标
// display.drawBitmap(10, yPos-5, iconArray[i], 32, 32, COLOR_RGB565_WHITE);
}
display.setTextSize(1);
display.setCursor(50, 200);
display.setTextColor(COLOR_RGB565_GREEN);
display.print("Short: Select | Long: Enter");
}
void updateMainMenu(ButtonEvent event) {
switch (event) {
case SHORT_PRESS:
menuSelection = (menuSelection + 1) % 2; // 在0和1之间循环
drawMainMenu(); // 重绘菜单以更新高亮
break;
case LONG_PRESS:
if (menuSelection == 0) {
currentState = PLAYING_SNAKE;
initSnakeGame(); // 初始化贪吃蛇游戏
} else {
currentState = PLAYING_BOUNCE;
initBounceGame(); // 初始化弹球游戏
}
break;
default:
break;
}
}
5.4 贪吃蛇游戏逻辑实现详解
贪吃蛇游戏是状态机和控制逻辑的经典结合。我们需要管理蛇身(一个坐标数组)、食物位置、当前移动方向和游戏状态(进行中/结束)。
// 贪吃蛇游戏相关变量
#define GRID_SIZE 10 // 每个网格的像素大小
#define GRID_WIDTH 32 // 网格宽度 (320/10)
#define GRID_HEIGHT 24 // 网格高度 (240/10)
struct Point { int x; int y; };
Point snake[100]; // 蛇身坐标数组
int snakeLength = 3;
Point food;
enum Direction { UP, RIGHT, DOWN, LEFT };
Direction currentDir = RIGHT;
bool gameRunning = true;
void initSnakeGame() {
snakeLength = 3;
// 初始化蛇在屏幕中央水平排列
for (int i = 0; i < snakeLength; i++) {
snake[i].x = GRID_WIDTH / 2 - i;
snake[i].y = GRID_HEIGHT / 2;
}
currentDir = RIGHT;
generateFood();
gameRunning = true;
}
void generateFood() {
bool onSnake;
do {
onSnake = false;
food.x = random(0, GRID_WIDTH);
food.y = random(0, GRID_HEIGHT);
for (int i = 0; i < snakeLength; i++) {
if (snake[i].x == food.x && snake[i].y == food.y) {
onSnake = true;
break;
}
}
} while (onSnake);
}
void updateSnakeGame(ButtonEvent event) {
if (!gameRunning) {
if (event == SHORT_PRESS || event == LONG_PRESS) {
// 游戏结束,按任意键返回主菜单
currentState = MAIN_MENU;
drawMainMenu();
}
return;
}
// 处理输入:短按改变方向(顺时针)
if (event == SHORT_PRESS) {
switch (currentDir) {
case UP: currentDir = RIGHT; break;
case RIGHT: currentDir = DOWN; break;
case DOWN: currentDir = LEFT; break;
case LEFT: currentDir = UP; break;
}
}
// 游戏逻辑更新(基于时间,非按钮事件驱动)
static unsigned long lastMoveTime = 0;
const unsigned long moveInterval = 200; // 每200毫秒移动一次
if (millis() - lastMoveTime > moveInterval) {
lastMoveTime = millis();
// 1. 计算新的蛇头位置
Point newHead = snake[0];
switch (currentDir) {
case UP: newHead.y--; break;
case RIGHT: newHead.x++; break;
case DOWN: newHead.y++; break;
case LEFT: newHead.x--; break;
}
// 2. 碰撞检测:撞墙或撞到自己
if (newHead.x < 0 || newHead.x >= GRID_WIDTH ||
newHead.y < 0 || newHead.y >= GRID_HEIGHT) {
gameOver();
return;
}
for (int i = 0; i < snakeLength; i++) {
if (snake[i].x == newHead.x && snake[i].y == newHead.y) {
gameOver();
return;
}
}
// 3. 检查是否吃到食物
bool ateFood = (newHead.x == food.x && newHead.y == food.y);
// 4. 移动蛇身:所有身体段向前移动一格
for (int i = snakeLength; i > 0; i--) {
snake[i] = snake[i-1];
}
snake[0] = newHead; // 放置新蛇头
// 5. 如果吃到食物,蛇长度增加,生成新食物
if (ateFood) {
snakeLength++;
generateFood();
playEatSound(); // 播放音效
}
// 6. 重绘游戏画面
drawSnakeGame();
}
}
void drawSnakeGame() {
display.fillScreen(COLOR_RGB565_BLACK);
// 绘制网格线(可选)
// 绘制蛇身
for (int i = 0; i < snakeLength; i++) {
int x = snake[i].x * GRID_SIZE;
int y = snake[i].y * GRID_SIZE;
display.fillRect(x, y, GRID_SIZE, GRID_SIZE, COLOR_RGB565_GREEN);
// 蛇头可以用不同颜色
if (i == 0) {
display.fillRect(x+1, y+1, GRID_SIZE-2, GRID_SIZE-2, COLOR_RGB565_DARKGREEN);
}
}
// 绘制食物
int foodX = food.x * GRID_SIZE;
int foodY = food.y * GRID_SIZE;
display.fillCircle(foodX + GRID_SIZE/2, foodY + GRID_SIZE/2, GRID_SIZE/2 - 1, COLOR_RGB565_RED);
// 绘制分数
display.setTextSize(1);
display.setTextColor(COLOR_RGB565_WHITE);
display.setCursor(5, 5);
display.print("Score: ");
display.print(snakeLength - 3);
}
5.5 弹球游戏逻辑实现详解
弹球游戏需要模拟一个球在屏幕内反弹,以及一个由玩家控制的挡板。
// 弹球游戏相关变量
int ballX, ballY;
int ballSpeedX = 3, ballSpeedY = 3; // 像素每帧
int paddleX, paddleY;
const int PADDLE_WIDTH = 40;
const int PADDLE_HEIGHT = 8;
const int BALL_SIZE = 6;
int bounceScore = 0;
bool ballActive = true;
void initBounceGame() {
ballX = display.width() / 2;
ballY = 30;
ballSpeedX = random(2, 5); // 随机初始方向
ballSpeedY = random(2, 5);
paddleX = display.width() / 2 - PADDLE_WIDTH / 2;
paddleY = display.height() - 20;
bounceScore = 0;
ballActive = true;
}
void updateBounceGame(ButtonEvent event) {
if (!ballActive) {
if (event == SHORT_PRESS || event == LONG_PRESS) {
currentState = MAIN_MENU;
drawMainMenu();
}
return;
}
// 控制挡板:短按向左,长按向右?(这里需要设计,例如按住按钮移动,释放停止)
// 另一种更直观的设计:按钮按下时,挡板向左移动;松开时,挡板向右移动(或反之)。
// 这里采用“按下左移,释放右移”的自动居中设计。
static bool buttonHeldForPaddle = false;
const int paddleSpeed = 5;
if (event == SHORT_PRESS) {
// 短按事件,可以用于发射球或其它功能,这里我们先用于控制
// 我们换一种思路:按钮状态直接控制挡板移动方向
}
// 实时读取按钮状态控制挡板(在loop中处理,而非事件)
// 这部分逻辑更适合放在主循环中根据按钮电平实时更新,而不是事件。
// 我们可以在主循环中根据 digitalRead(BUTTON_PIN) 来更新 paddleX。
// 球的位置更新
static unsigned long lastUpdateTime = 0;
const unsigned long updateInterval = 30; // 约33帧/秒
if (millis() - lastUpdateTime > updateInterval) {
lastUpdateTime = millis();
ballX += ballSpeedX;
ballY += ballSpeedY;
// 边界碰撞检测(左右墙)
if (ballX <= 0 || ballX >= display.width() - BALL_SIZE) {
ballSpeedX = -ballSpeedX;
playBounceSound();
}
// 上墙碰撞
if (ballY <= 0) {
ballSpeedY = -ballSpeedY;
playBounceSound();
}
// 与挡板碰撞检测
if (ballY + BALL_SIZE >= paddleY &&
ballY <= paddleY + PADDLE_HEIGHT &&
ballX + BALL_SIZE >= paddleX &&
ballX <= paddleX + PADDLE_WIDTH) {
ballSpeedY = -abs(ballSpeedY); // 确保向上弹
// 根据击中挡板的位置微调水平速度,增加可玩性
int hitPos = ballX - paddleX;
ballSpeedX = map(hitPos, 0, PADDLE_WIDTH, -5, 5);
bounceScore++;
playBounceSound();
}
// 掉落检测(游戏失败)
if (ballY >= display.height()) {
ballActive = false;
playGameOverSound();
}
drawBounceGame();
}
}
void drawBounceGame() {
display.fillScreen(COLOR_RGB565_BLACK);
// 绘制球
display.fillCircle(ballX + BALL_SIZE/2, ballY + BALL_SIZE/2, BALL_SIZE/2, COLOR_RGB565_YELLOW);
// 绘制挡板
display.fillRect(paddleX, paddleY, PADDLE_WIDTH, PADDLE_HEIGHT, COLOR_RGB565_CYAN);
// 绘制分数
display.setTextSize(2);
display.setTextColor(COLOR_RGB565_WHITE);
display.setCursor(10, 10);
display.print("Score: ");
display.print(bounceScore);
if (!ballActive) {
display.setCursor(50, 100);
display.print("GAME OVER");
display.setTextSize(1);
display.setCursor(40, 130);
display.print("Press Button to Menu");
}
}
5.6 主循环的最终整合
最后,我们将所有部分整合到 loop() 函数中,形成一个完整的、响应迅速的游戏机系统。
void loop() {
// 1. 读取按钮事件(非阻塞)
ButtonEvent btnEvent = readButtonEvent();
// 2. 根据当前状态处理事件和更新逻辑
switch (currentState) {
case MAIN_MENU:
updateMainMenu(btnEvent);
// 主菜单没有持续的动画,可以稍微延时降低CPU占用,但最好不用delay
// delay(50);
break;
case PLAYING_SNAKE:
updateSnakeGame(btnEvent);
// 贪吃蛇的移动由内部的定时器控制,这里不需要额外延时
break;
case PLAYING_BOUNCE:
// 弹球游戏需要实时读取按钮状态来控制挡板(非事件模式)
// 这是一种混合控制:逻辑更新靠定时,挡板控制靠实时电平
bool buttonPressed = (digitalRead(BUTTON_PIN) == LOW);
const int paddleSpeed = 5;
if (buttonPressed) {
paddleX -= paddleSpeed; // 按下左移
if (paddleX < 0) paddleX = 0;
} else {
paddleX += paddleSpeed; // 松开右移(或自动回中)
if (paddleX > display.width() - PADDLE_WIDTH) paddleX = display.width() - PADDLE_WIDTH;
}
updateBounceGame(btnEvent); // 事件仍可用于处理游戏开始/结束
break;
case GAME_OVER:
// 处理游戏结束后的返回
if (btnEvent != NO_EVENT) {
currentState = MAIN_MENU;
drawMainMenu();
}
break;
}
// 3. 可以在这里添加一个极短的延时来稳定循环周期,非必须
// delay(1);
}
6. 系统调试、优化与常见问题排查
6.1 硬件连接检查与上电顺序
问题最常出现在硬件连接上。首先,务必 断电操作 。按照接线表逐一检查杜邦线是否插紧,特别是VCC和GND不要接反。一个很好的习惯是使用不同颜色的导线区分电源(红色)、地(黑色)和信号线(黄、绿、蓝等)。上电前,用万用表通断档检查电源和地之间是否短路。
上电顺序建议:先连接好所有线路,最后再插入USB或电池。观察ESP32板载的电源指示灯是否正常点亮。如果屏幕是独立供电(非从ESP32取电),请注意其电压要求(通常是3.3V或5V),务必匹配。
6.2 软件编译与上传故障排除
-
编译错误“fatal error: DFRobot_GDL.h: No such file or directory” :
- 原因 :库未正确安装或路径不对。
- 解决 :在Arduino IDE中,通过“项目”->“加载库”->“管理库…”重新搜索并安装DFRobot_GDL。安装后,重启IDE。确保
#include <DFRobot_GDL.h>的拼写正确。
-
上传失败“Failed to connect to ESP32: Timed out waiting for packet header” :
- 原因 :最常见的原因是开发板上的自动下载电路未能正确触发,或者端口被占用。
- 解决 :
- 确保在IDE中选择了正确的端口。
- 尝试按住ESP32板上的“BOOT”按钮(或有的板子是“IO0”按钮),然后按一下“RST”按钮,再松开“BOOT”按钮,使芯片进入下载模式,然后立即点击上传。
- 关闭所有可能占用串口的软件(如串口监视器、其他IDE)。
- 换一条质量好的USB数据线。
-
程序上传成功但屏幕无显示 :
- 原因 :屏幕初始化失败,可能是引脚定义错误、SPI频率不匹配或屏幕驱动芯片型号不对。
- 解决 :
- 检查代码中的
TFT_DC、TFT_CS、TFT_RST引脚号是否与实际接线一致。 - 在
display.begin()之后,添加一小段延时delay(500),给屏幕足够的启动时间。 - 查阅屏幕规格书,确认驱动芯片型号(如ST7789)。在
DFRobot_GDL的构造函数中,可能需要指定型号,例如DFRobot_ST7789_240x320_HW_SPI。查看库的示例代码,模仿其初始化方式。 - 尝试降低SPI时钟频率,在初始化代码中寻找相关设置(有时在库的
.begin()函数参数中)。
- 检查代码中的
6.3 按钮响应不灵或连击问题
-
按下无反应 :
- 原因 :接线错误、上拉电阻未启用、引脚模式设置错误或消抖延时过长。
- 解决 :用
digitalRead()读取引脚电平并在串口监视器中打印,确认按下时电平从高变低。检查pinMode(BUTTON_PIN, INPUT_PULLUP)。调整debounceDelay值,从20ms开始尝试。
-
一次按下触发多次事件 :
- 原因 :消抖逻辑有误,或按钮本身质量差、抖动严重。
- 解决 :确保消抖代码中,只有在稳定状态变化后才更新
buttonState。可以尝试增加debounceDelay到50-100ms。如果硬件允许,在按钮两端并联一个0.1uF的电容到地,可以硬件消抖。
6.4 显示花屏、闪烁或颜色异常
-
花屏/乱码 :
- 原因 :SPI时钟速度太快,导致数据传输错误;或者内存不足,屏幕缓冲区溢出。
- 解决 :尝试在初始化时降低SPI频率。ESP32的硬件SPI速度很快,但有些屏幕驱动芯片在高速下不稳定。在库的初始化函数中寻找设置时钟的参数。确保没有在循环中动态创建大量消耗内存的对象。
-
屏幕闪烁 :
- 原因 :可能是整个屏幕在每次循环都被清空重绘,而重绘速度跟不上,导致撕裂感。
- 解决 :优化绘图代码,只重绘发生变化的部分(脏矩形更新)。对于小游戏,如果整体重绘很快(一帧在16ms内,即60FPS),则闪烁不明显。确保
draw函数中用的是fillScreen清屏,而不是交替画黑白。
-
颜色反相或错乱 :
- 原因 :屏幕的像素数据格式(如RGB565, BGR565)与代码中颜色常量格式不匹配。
- 解决 :查阅屏幕数据手册,确认其颜色格式。
COLOR_RGB565_WHITE等常量是RGB565格式。如果屏幕是BGR格式,显示出来的颜色就会错乱。有些显示库提供设置颜色格式的函数。
6.5 性能优化与功耗管理
-
游戏卡顿 :
- 原因 :
draw函数过于复杂,或者loop中包含了阻塞操作(如delay)。 - 解决 :
- 使用
millis()进行非阻塞定时,杜绝delay()。 - 简化绘图操作,使用底层
drawPixel或fillRect代替复杂的print(文字渲染较慢)。 - 将固定不变的UI元素(如边框、标题)绘制到内存中,或者只绘制一次。
- 使用
- 原因 :
-
功耗过高(使用电池时) :
- 原因 :ESP32全速运行,屏幕常亮。
- 解决 :
- 在菜单闲置一段时间后,进入低功耗模式:调用
display.sleep()关闭屏幕背光,使用esp_sleep_enable_timer_wakeup()和esp_light_sleep_start()让ESP32进入轻睡眠。当按钮按下时,通过外部中断唤醒系统。 - 降低ESP32的CPU频率(在Arduino IDE的板卡设置中选择“80MHz”而非“240MHz”),这能显著降低功耗且对简单游戏性能影响不大。
- 在菜单闲置一段时间后,进入低功耗模式:调用
7. 项目扩展与进阶玩法
完成基础版本后,这个游戏机平台还有巨大的潜力可以挖掘。
-
增加更多游戏 :框架已经搭好,添加新游戏就是创建一个新的状态和对应的
update、draw函数。可以尝试实现“打飞机”、“俄罗斯方块”甚至简单的“RPG地图探索”。 -
丰富交互方式 :单按钮是极简的挑战,但也可以增加一个摇杆或额外的按钮来实现更复杂的控制。或者,利用ESP32-C6的蓝牙功能,连接一个手机作为无线手柄。
-
添加声音与音效 :目前的蜂鸣器只能发出单调的声音。可以尝试用PWM驱动无源蜂鸣器,播放简单的旋律(如游戏开始、吃到食物、游戏结束的短曲)。网上有很多将乐谱转换为频率和延时数组的教程和工具。
-
实现分数记录与排行榜 :利用ESP32的Wi-Fi功能,在游戏结束后将分数上传到某个网络服务器(如简单的HTTP POST请求),或者写入板载的SPIFFS文件系统中,实现本地历史最高分记录。
-
设计并3D打印外壳 :为你的游戏机设计一个专属外壳,让它从一块开发板变成一个真正的掌机。可以使用Fusion 360或Tinkercad进行建模,然后3D打印出来。别忘了为屏幕、按钮和USB接口开孔。
-
优化电源管理 :设计一个带开关的电池盒,并增加一个低压检测电路,当电池电压过低时在屏幕上提示充电,保护锂电池。
这个“Button Freak”项目就像一颗种子,从最简单的硬件和交互开始,你可以根据自己的兴趣和技能,让它生长出各种不同的形态。嵌入式开发的乐趣就在于此:用代码赋予硬件生命,在有限的资源中创造无限的乐趣。每一次调试成功的喜悦,每一个新功能实现的成就感,都是这个过程中最宝贵的收获。当你拿着自己亲手制作、编写每一行代码的游戏机玩上一局时,那种感觉是购买任何成品都无法替代的。
更多推荐


所有评论(0)