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 (访问地址可通过搜索获得)。打开网站后:

  1. 点击“Choose File”上传你的PNG图标。
  2. 在“Canvas size”中确保宽度和高度都是32。
  3. “Scanning method”选择“Vertical (1 bit per pixel)”或根据你的颜色深度选择。对于单色图标,1位每像素(黑白)就够了。
  4. “Code output format”选择“Arduino Code”。
  5. “Draw mode”选择“Vertical - 1 bit per pixel”。
  6. 最关键的是“Output”部分,它会实时生成一个 const unsigned char 数组。你可以给数组起个名字,比如 snake_icon
  7. 点击“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() 循环中,我们做以下几件事:

  1. 读取按钮状态 :调用一个 readButton() 函数,该函数会判断按钮是“未按下”、“短按”还是“长按”,并返回一个事件类型。
  2. 更新游戏状态 :根据当前程序状态( currentState )和接收到的事件,调用相应的处理函数。例如,在 STATE_GAME_SNAKE 下,收到“短按”事件可能意味着让蛇转向。
  3. 渲染显示 :根据当前状态更新屏幕内容。菜单就画菜单,游戏就画游戏画面。
  4. 处理游戏逻辑 :例如,移动贪吃蛇、计算弹球位置等。这些逻辑更新应该基于时间间隔(使用 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 软件编译与上传故障排除

  1. 编译错误“fatal error: DFRobot_GDL.h: No such file or directory”

    • 原因 :库未正确安装或路径不对。
    • 解决 :在Arduino IDE中,通过“项目”->“加载库”->“管理库…”重新搜索并安装DFRobot_GDL。安装后,重启IDE。确保 #include <DFRobot_GDL.h> 的拼写正确。
  2. 上传失败“Failed to connect to ESP32: Timed out waiting for packet header”

    • 原因 :最常见的原因是开发板上的自动下载电路未能正确触发,或者端口被占用。
    • 解决
      • 确保在IDE中选择了正确的端口。
      • 尝试按住ESP32板上的“BOOT”按钮(或有的板子是“IO0”按钮),然后按一下“RST”按钮,再松开“BOOT”按钮,使芯片进入下载模式,然后立即点击上传。
      • 关闭所有可能占用串口的软件(如串口监视器、其他IDE)。
      • 换一条质量好的USB数据线。
  3. 程序上传成功但屏幕无显示

    • 原因 :屏幕初始化失败,可能是引脚定义错误、SPI频率不匹配或屏幕驱动芯片型号不对。
    • 解决
      • 检查代码中的 TFT_DC TFT_CS TFT_RST 引脚号是否与实际接线一致。
      • display.begin() 之后,添加一小段延时 delay(500) ,给屏幕足够的启动时间。
      • 查阅屏幕规格书,确认驱动芯片型号(如ST7789)。在 DFRobot_GDL 的构造函数中,可能需要指定型号,例如 DFRobot_ST7789_240x320_HW_SPI 。查看库的示例代码,模仿其初始化方式。
      • 尝试降低SPI时钟频率,在初始化代码中寻找相关设置(有时在库的 .begin() 函数参数中)。

6.3 按钮响应不灵或连击问题

  1. 按下无反应

    • 原因 :接线错误、上拉电阻未启用、引脚模式设置错误或消抖延时过长。
    • 解决 :用 digitalRead() 读取引脚电平并在串口监视器中打印,确认按下时电平从高变低。检查 pinMode(BUTTON_PIN, INPUT_PULLUP) 。调整 debounceDelay 值,从20ms开始尝试。
  2. 一次按下触发多次事件

    • 原因 :消抖逻辑有误,或按钮本身质量差、抖动严重。
    • 解决 :确保消抖代码中,只有在稳定状态变化后才更新 buttonState 。可以尝试增加 debounceDelay 到50-100ms。如果硬件允许,在按钮两端并联一个0.1uF的电容到地,可以硬件消抖。

6.4 显示花屏、闪烁或颜色异常

  1. 花屏/乱码

    • 原因 :SPI时钟速度太快,导致数据传输错误;或者内存不足,屏幕缓冲区溢出。
    • 解决 :尝试在初始化时降低SPI频率。ESP32的硬件SPI速度很快,但有些屏幕驱动芯片在高速下不稳定。在库的初始化函数中寻找设置时钟的参数。确保没有在循环中动态创建大量消耗内存的对象。
  2. 屏幕闪烁

    • 原因 :可能是整个屏幕在每次循环都被清空重绘,而重绘速度跟不上,导致撕裂感。
    • 解决 :优化绘图代码,只重绘发生变化的部分(脏矩形更新)。对于小游戏,如果整体重绘很快(一帧在16ms内,即60FPS),则闪烁不明显。确保 draw 函数中用的是 fillScreen 清屏,而不是交替画黑白。
  3. 颜色反相或错乱

    • 原因 :屏幕的像素数据格式(如RGB565, BGR565)与代码中颜色常量格式不匹配。
    • 解决 :查阅屏幕数据手册,确认其颜色格式。 COLOR_RGB565_WHITE 等常量是RGB565格式。如果屏幕是BGR格式,显示出来的颜色就会错乱。有些显示库提供设置颜色格式的函数。

6.5 性能优化与功耗管理

  1. 游戏卡顿

    • 原因 draw 函数过于复杂,或者 loop 中包含了阻塞操作(如 delay )。
    • 解决
      • 使用 millis() 进行非阻塞定时,杜绝 delay()
      • 简化绘图操作,使用底层 drawPixel fillRect 代替复杂的 print (文字渲染较慢)。
      • 将固定不变的UI元素(如边框、标题)绘制到内存中,或者只绘制一次。
  2. 功耗过高(使用电池时)

    • 原因 :ESP32全速运行,屏幕常亮。
    • 解决
      • 在菜单闲置一段时间后,进入低功耗模式:调用 display.sleep() 关闭屏幕背光,使用 esp_sleep_enable_timer_wakeup() esp_light_sleep_start() 让ESP32进入轻睡眠。当按钮按下时,通过外部中断唤醒系统。
      • 降低ESP32的CPU频率(在Arduino IDE的板卡设置中选择“80MHz”而非“240MHz”),这能显著降低功耗且对简单游戏性能影响不大。

7. 项目扩展与进阶玩法

完成基础版本后,这个游戏机平台还有巨大的潜力可以挖掘。

  1. 增加更多游戏 :框架已经搭好,添加新游戏就是创建一个新的状态和对应的 update draw 函数。可以尝试实现“打飞机”、“俄罗斯方块”甚至简单的“RPG地图探索”。

  2. 丰富交互方式 :单按钮是极简的挑战,但也可以增加一个摇杆或额外的按钮来实现更复杂的控制。或者,利用ESP32-C6的蓝牙功能,连接一个手机作为无线手柄。

  3. 添加声音与音效 :目前的蜂鸣器只能发出单调的声音。可以尝试用PWM驱动无源蜂鸣器,播放简单的旋律(如游戏开始、吃到食物、游戏结束的短曲)。网上有很多将乐谱转换为频率和延时数组的教程和工具。

  4. 实现分数记录与排行榜 :利用ESP32的Wi-Fi功能,在游戏结束后将分数上传到某个网络服务器(如简单的HTTP POST请求),或者写入板载的SPIFFS文件系统中,实现本地历史最高分记录。

  5. 设计并3D打印外壳 :为你的游戏机设计一个专属外壳,让它从一块开发板变成一个真正的掌机。可以使用Fusion 360或Tinkercad进行建模,然后3D打印出来。别忘了为屏幕、按钮和USB接口开孔。

  6. 优化电源管理 :设计一个带开关的电池盒,并增加一个低压检测电路,当电池电压过低时在屏幕上提示充电,保护锂电池。

这个“Button Freak”项目就像一颗种子,从最简单的硬件和交互开始,你可以根据自己的兴趣和技能,让它生长出各种不同的形态。嵌入式开发的乐趣就在于此:用代码赋予硬件生命,在有限的资源中创造无限的乐趣。每一次调试成功的喜悦,每一个新功能实现的成就感,都是这个过程中最宝贵的收获。当你拿着自己亲手制作、编写每一行代码的游戏机玩上一局时,那种感觉是购买任何成品都无法替代的。

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐