1. 从一块开发板到一个游戏机我的贪吃蛇项目缘起前阵子手头正好有几块乐鑫的Beetle ESP32-C3开发板这板子小巧得像个U盘但功能却一点不含糊集成了Wi-Fi、蓝牙还有足够的GPIO。看着它我就在想除了做那些常规的物联网传感器节点、智能开关能不能用它干点更有趣、更“复古”的事情比如做一个能拿在手里玩的游戏机。这个念头一冒出来就再也按不下去了。最终我选择了贪吃蛇——这个几乎刻在每个人数字记忆里的经典游戏作为我的第一个嵌入式游戏项目。为什么是贪吃蛇首先它的逻辑相对清晰核心就是控制一条蛇移动、吃食物、变长同时不能撞墙或撞到自己。这对于嵌入式开发来说是一个绝佳的练手项目既能涵盖输入按键、输出显示、游戏逻辑、状态机等核心概念又不会因为过于复杂而让人望而却步。其次它的视觉效果简单对显示设备的要求不高一块小小的OLED屏幕就能完美呈现。最后它充满了情怀亲手做出一个能玩的贪吃蛇那种成就感远超点亮一个LED灯。这个项目的核心就是利用Beetle ESP32-C3这块MCU驱动一块OLED显示屏并读取几个按键的状态来实现完整的贪吃蛇游戏逻辑。整个过程涉及硬件连接、驱动库的使用、游戏状态机的设计、显示刷新优化等多个环节。下面我就把自己从零搭建这个项目的完整过程、遇到的坑以及解决思路毫无保留地分享出来。无论你是嵌入式新手想找个有趣的项目入门还是老手想重温一下基础相信都能从中找到一些有用的东西。2. 硬件选型与连接为什么是这些部件一个完整的贪吃蛇游戏系统离不开“大脑”、“眼睛”和“手脚”。在这个项目里Beetle ESP32-C3是大脑负责所有的计算和控制OLED屏幕是眼睛负责将游戏画面呈现给我们按键则是手脚是我们与游戏交互的桥梁。2.1 核心控制器Beetle ESP32-C3的独特优势我选择Beetle ESP32-C3而不是更常见的ESP32 DevKit或者Arduino Uno有几个很实际的理由。第一是尺寸它的长宽只有25*20.5mm非常节省空间适合做手持设备。第二是集成度它板载了CH340C USB转串口芯片和Type-C接口这意味着你只需要一根普通的手机数据线就能进行供电和程序烧录省去了外接下载器的麻烦对于桌面快速原型开发极其友好。第三是性能与功耗的平衡ESP32-C3是一款基于RISC-V架构的单核芯片主频160MHz内存400KB运行一个贪吃蛇游戏绰绰有余而且它支持蓝牙5.0为未来扩展无线手柄留下了可能虽然本项目暂未使用。它的引脚虽然不多但对我们这个项目来说完全够用。我们需要关注的是I2C引脚用于连接OLED以及几个普通的GPIO用于连接按键。2.2 显示设备SSD1306 OLED屏的选择与考量显示部分我选择了0.96英寸的128x64像素的OLED屏幕驱动芯片是SSD1306。这是目前最流行、性价比最高的微型显示屏方案之一。选择它有几个原因一是自发光对比度极高黑色纯粹显示效果远超LCD尤其在暗环境下游戏体验很棒二是功耗低只有需要点亮的像素才耗电三是接口简单通常支持I2C或SPI通信I2C只需要两根数据线SDA, SCL非常适合引脚紧张的场景。这里有一个细节需要注意市面上有些OLED模块的I2C地址是0x3C有些是0x3D。在代码中我们需要正确指定否则无法通信。通常0x3C更常见。购买时最好确认一下或者代码里做好兼容。2.3 输入设备按键与消抖处理为了控制蛇的移动我选择了最经典的十字方向按键布局上、下、左、右四个独立按键。另外还增加了一个“确认/开始”按键。按键的另一端都连接到GND这种接法称为“下拉电阻”模式当按键未按下时MCU的GPIO口需要通过内部或外部上拉电阻保持在高电平按键按下时GPIO被拉低到GND变为低电平。ESP32-C3的GPIO都支持内部上拉所以我们不需要外接电阻简化了电路。按键消抖是必须处理的问题。机械按键在按下和弹起的瞬间金属触点会发生物理抖动导致电平在短时间内快速变化多次。如果不处理一次按键可能会被误判为多次按下。软件消抖是简单有效的方法其逻辑是当检测到按键电平变化如从高变低时不立即认为按键按下而是等待一段时间比如10-50毫秒再次读取引脚电平如果仍然是按下状态才确认是一次有效的按键动作。2.4 硬件连接清单与接线图以下是完整的物料清单和接线方式元件型号/规格数量说明主控板Beetle ESP32-C31核心控制器OLED屏0.96 SSD1306 (I2C)1128x64分辨率按键6x6mm 轻触开关5上下左右及确认键连接线杜邦线母对母若干用于连接接线表根据Beetle ESP32-C3引脚定义Beetle ESP32-C3引脚连接至备注3V3OLED屏 VCC提供3.3V电源GNDOLED屏 GND, 所有按键一端共地GPIO4OLED屏 SDA (数据线)I2C数据GPIO5OLED屏 SCL (时钟线)I2C时钟GPIO6按键 UP (上)内部上拉按下为低GPIO7按键 DOWN (下)内部上拉按下为低GPIO8按键 LEFT (左)内部上拉按下为低GPIO9按键 RIGHT (右)内部上拉按下为低GPIO10按键 OK (确认/开始)内部上拉按下为低注意务必确认OLED屏支持3.3V供电。大部分模块是兼容3.3V-5V的但直接用3.3V最安全。接线时最好先断电接好检查无误后再上电。3. 软件开发环境搭建与核心库介绍硬件连接好后我们需要让代码跑起来。首先得搭建开发环境。3.1 安装Arduino IDE与ESP32开发板支持虽然ESP32-C3可以用乐鑫官方的ESP-IDF框架开发但对于这样一个逻辑相对简单的项目使用Arduino IDE会更快捷生态中的库也更丰富。具体步骤安装Arduino IDE从官网下载并安装最新版。添加ESP32开发板支持打开Arduino IDE进入“文件 - 首选项”在“附加开发板管理器网址”中输入https://espressif.github.io/arduino-esp32/package_esp32_index.json可以同时添加多个URL用逗号隔开。安装开发板包打开“工具 - 开发板 - 开发板管理器”搜索“esp32”找到由“Espressif Systems”提供的“esp32”平台点击安装。这个过程可能会比较慢需要耐心等待。选择开发板安装完成后在“工具 - 开发板”中选择“ESP32C3 Dev Module”。虽然列表里可能没有Beetle ESP32-C3的直接选项但这个通用模块是兼容的。配置参数选择开发板后需要对以下几个参数进行配置Upload Speed: 921600 (提高烧录速度)Flash Mode: DIO (默认)Flash Size: 4MB (Beetle ESP32-C3的配置)Partition Scheme: Default 4MB with spiffs (或 Huge APP)**Core Debug Level: 无Port: 插入开发板后系统会识别出一个新的串口如COM3, /dev/ttyUSB0选择它。3.2 核心库U8g2与EasyButton我们需要两个关键的Arduino库来简化开发U8g2库这是一个功能极其强大的单色显示屏驱动库支持上百种控制器和显示屏SSD1306自然不在话下。它的API统一一旦学会驱动其他屏幕也轻而易举。在Arduino IDE的“库管理器”中搜索“U8g2”由olikraus开发的那个就是安装它。为什么选U8g2而不是Adafruit SSD1306Adafruit的库也很优秀但U8g2在字体、图形绘制以及内存占用优化上更胜一筹特别是对于动态游戏画面的处理其缓冲区管理方式更灵活。EasyButton库这个库封装了按键检测的所有繁琐逻辑包括消抖、单击、长按、双击等事件的检测。我们用它来处理五个游戏按键可以让代码非常清晰。同样在库管理中搜索“EasyButton”并安装。安装好这两个库我们的软件武器就备齐了。4. 贪吃蛇游戏逻辑的代码实现详解这是项目的核心。我们将游戏逻辑分解为几个部分游戏状态定义、蛇与食物的数据结构、主循环控制、按键处理与画面渲染。4.1 游戏状态与全局变量定义首先我们需要定义游戏可能处于的几种状态这通常用一个枚举enum来表示。然后定义蛇的身体、食物位置、移动方向等核心数据结构。#include U8g2lib.h #include EasyButton.h // 游戏状态枚举 enum GameState { MENU, // 主菜单 PLAYING, // 游戏中 GAME_OVER // 游戏结束 }; // 方向枚举 enum Direction { UP, DOWN, LEFT, RIGHT }; // 全局变量 GameState currentState MENU; int score 0; int highScore 0; // 蛇的数据结构用一个数组存储身体每个部分的坐标snakeLength是当前长度 const int MAX_SNAKE_LENGTH 100; // 蛇的最大长度根据屏幕格子数设定 int snakeX[MAX_SNAKE_LENGTH]; int snakeY[MAX_SNAKE_LENGTH]; int snakeLength 3; // 初始长度 Direction currentDir RIGHT; // 初始方向 Direction nextDir RIGHT; // 下一帧的方向用于防止同一帧内反向操作 // 食物坐标 int foodX, foodY; // 游戏区域定义基于像素但我们将按格子处理 const int GRID_SIZE 8; // 每个格子8x8像素 const int GRID_WIDTH 128 / GRID_SIZE; // 16列 const int GRID_HEIGHT 64 / GRID_SIZE; // 8行 // 初始化U8g2对象参数根据你的OLED屏连接方式调整I2C U8G2_SSD1306_128X64_NONAME_F_HW_I2C u8g2(U8G2_R0, /* reset*/ U8X8_PIN_NONE, /* clock*/ 5, /* data*/ 4); // Beetle ESP32-C3: SCLGPIO5, SDAGPIO4 // 初始化EasyButton对象连接到对应引脚并启用内部上拉 EasyButton btnUp(6); EasyButton btnDown(7); EasyButton btnLeft(8); EasyButton btnRight(9); EasyButton btnOk(10);4.2 初始化设置setup函数在setup()函数中我们需要初始化串口用于调试、显示屏、按键并生成第一个食物。void setup() { Serial.begin(115200); // 初始化串口调试用 Serial.println(Snake Game Starting...); // 初始化显示屏 u8g2.begin(); u8g2.setFont(u8g2_font_6x10_tf); // 设置一个清晰的字体 u8g2.setDrawColor(1); // 设置绘制颜色为白色在OLED上是亮 // 初始化按键 btnUp.begin(); btnDown.begin(); btnLeft.begin(); btnRight.begin(); btnOk.begin(); // 初始化蛇的初始位置例如在屏幕中间水平放置 snakeLength 3; for (int i 0; i snakeLength; i) { snakeX[i] GRID_WIDTH / 2 - i; // 头部在中间身体向左延伸 snakeY[i] GRID_HEIGHT / 2; } currentDir RIGHT; nextDir RIGHT; // 生成第一个食物 generateFood(); // 绘制初始菜单 drawMenu(); }4.3 按键扫描与方向控制逻辑在loop()函数中我们需要持续扫描按键。这里的关键点是防止蛇直接反向移动例如正在向右移动时不能立即按左键让蛇头向左这会导致游戏瞬间结束。我们通过currentDir和nextDir两个变量来实现。void loop() { // 1. 扫描所有按键状态 btnUp.read(); btnDown.read(); btnLeft.read(); btnRight.read(); btnOk.read(); // 2. 根据当前游戏状态处理按键 switch (currentState) { case MENU: if (btnOk.wasReleased()) { // 按确认键开始游戏 currentState PLAYING; resetGame(); // 重置游戏数据 } break; case PLAYING: // 处理方向键只允许非反向的输入 if (btnUp.wasReleased() currentDir ! DOWN) { nextDir UP; } if (btnDown.wasReleased() currentDir ! UP) { nextDir DOWN; } if (btnLeft.wasReleased() currentDir ! RIGHT) { nextDir LEFT; } if (btnRight.wasReleased() currentDir ! LEFT) { nextDir RIGHT; } // 游戏逻辑更新和绘制 updateGame(); drawGame(); break; case GAME_OVER: if (btnOk.wasReleased()) { // 按确认键返回菜单 currentState MENU; drawMenu(); } break; } // 添加一个小的延时控制游戏速度帧率 delay(100); // 100ms一帧即10 FPS这个值越小蛇移动越快 }4.4 游戏核心逻辑更新updateGame函数这是游戏的心脏每帧调用一次。它负责移动蛇、检查碰撞、判断是否吃到食物。void updateGame() { // 1. 更新当前移动方向 currentDir nextDir; // 2. 计算新的蛇头位置 int newHeadX snakeX[0]; int newHeadY snakeY[0]; switch (currentDir) { case UP: newHeadY--; break; case DOWN: newHeadY; break; case LEFT: newHeadX--; break; case RIGHT: newHeadX; break; } // 3. 边界检查撞墙 if (newHeadX 0 || newHeadX GRID_WIDTH || newHeadY 0 || newHeadY GRID_HEIGHT) { gameOver(); return; } // 4. 自身碰撞检查 for (int i 0; i snakeLength; i) { if (snakeX[i] newHeadX snakeY[i] newHeadY) { gameOver(); return; } } // 5. 检查是否吃到食物 bool ateFood (newHeadX foodX newHeadY foodY); if (ateFood) { score; // 增加分数 if (score highScore) highScore score; // 更新最高分 snakeLength; // 蛇长度增加 generateFood(); // 生成新食物 } // 6. 移动蛇的身体 // 从尾部向前遍历将前一节的位置赋值给后一节 for (int i snakeLength - 1; i 0; i--) { snakeX[i] snakeX[i - 1]; snakeY[i] snakeY[i - 1]; } // 设置新的蛇头位置 snakeX[0] newHeadX; snakeY[0] newHeadY; }4.5 食物生成函数generateFood食物的生成需要两个条件一是在游戏区域内二是不与蛇的身体重叠。这里采用一个简单的“随机尝试”算法。void generateFood() { bool onSnake; do { onSnake false; foodX random(GRID_WIDTH); // random(max) 生成 0 到 max-1 的随机数 foodY random(GRID_HEIGHT); // 检查是否与蛇身重叠 for (int i 0; i snakeLength; i) { if (snakeX[i] foodX snakeY[i] foodY) { onSnake true; break; } } } while (onSnake); // 如果重叠就重新生成 }4.6 画面绘制函数drawGame与drawMenu绘制函数负责将游戏状态转换为屏幕上的像素。U8g2库提供了丰富的绘图函数。void drawGame() { u8g2.clearBuffer(); // 清除显示缓冲区 // 1. 绘制蛇身 for (int i 0; i snakeLength; i) { // 将网格坐标转换为像素坐标。为了美观可以留1像素间隙。 int pixelX snakeX[i] * GRID_SIZE; int pixelY snakeY[i] * GRID_SIZE; // 绘制一个实心矩形代表身体一节 u8g2.drawBox(pixelX, pixelY, GRID_SIZE-1, GRID_SIZE-1); } // 2. 绘制食物例如画一个圆或小方块 int foodPixelX foodX * GRID_SIZE GRID_SIZE/2; int foodPixelY foodY * GRID_SIZE GRID_SIZE/2; u8g2.drawDisc(foodPixelX, foodPixelY, GRID_SIZE/2 - 1); // 画一个圆盘 // 3. 绘制分数和最高分在屏幕顶部 u8g2.setFont(u8g2_font_5x7_tf); // 换一个小字体 char scoreStr[20]; sprintf(scoreStr, S:%d H:%d, score, highScore); u8g2.drawStr(0, 7, scoreStr); // 在(0,7)像素位置绘制字符串 u8g2.sendBuffer(); // 将缓冲区内容发送到显示屏 } void drawMenu() { u8g2.clearBuffer(); u8g2.setFont(u8g2_font_10x20_tf); u8g2.drawStr(20, 25, SNAKE); u8g2.setFont(u8g2_font_6x10_tf); u8g2.drawStr(35, 45, Press OK); u8g2.sendBuffer(); } void drawGameOver() { u8g2.clearBuffer(); u8g2.setFont(u8g2_font_10x20_tf); u8g2.drawStr(15, 25, GAME OVER); u8g2.setFont(u8g2_font_6x10_tf); char scoreStr[20]; sprintf(scoreStr, Score: %d, score); u8g2.drawStr(40, 45, scoreStr); u8g2.sendBuffer(); }4.7 游戏状态切换与重置void gameOver() { currentState GAME_OVER; drawGameOver(); } void resetGame() { score 0; snakeLength 3; // 重置蛇的位置和方向 for (int i 0; i snakeLength; i) { snakeX[i] GRID_WIDTH / 2 - i; snakeY[i] GRID_HEIGHT / 2; } currentDir RIGHT; nextDir RIGHT; generateFood(); }将以上所有代码段整合到一个.ino文件中编译并上传到Beetle ESP32-C3一个完整的贪吃蛇游戏就运行起来了。5. 项目优化与深度调试心得代码能跑起来只是第一步要让游戏体验更好还需要进行一系列优化和调试。这里分享几个我实践中总结的关键点。5.1 游戏速度与帧率控制在loop()函数中我使用了delay(100)来控制速度。这是一个简单粗暴但有效的方法它让主循环每隔100ms运行一次。然而这有一个问题游戏逻辑更新updateGame和画面绘制drawGame的耗时也被包含在这100ms里了。如果某次绘制特别复杂比如蛇很长耗时超过100ms那么游戏的实际帧率就会下降感觉变卡。更优的做法是使用非阻塞式定时。我们可以用millis()函数记录时间确保无论逻辑和绘制花了多长时间蛇的移动间隔都是固定的。这能提供更稳定、更流畅的游戏体验。unsigned long previousGameUpdateTime 0; const long gameUpdateInterval 100; // 希望每100ms更新一次游戏逻辑 void loop() { unsigned long currentTime millis(); // 扫描按键这部分应该实时响应不受定时限制 btnUp.read(); // ... 其他按键扫描 // 根据状态处理按键同上 // ... if (currentState PLAYING) { // 检查是否到了该更新游戏逻辑的时间 if (currentTime - previousGameUpdateTime gameUpdateInterval) { previousGameUpdateTime currentTime; // 保存本次更新时间 updateGame(); // 更新逻辑 drawGame(); // 绘制画面 } // 在两次逻辑更新之间可以处理其他事情比如更频繁的按键扫描 // 或者直接空循环这比delay()更高效 } else { // 菜单和结束画面不需要频繁更新可以简单处理 // ... } }5.2 显示性能优化局部刷新与双缓冲U8g2的clearBuffer()和sendBuffer()会清空并重绘整个屏幕128x648192个像素。在贪吃蛇游戏中每帧只有蛇头、旧蛇尾和新食物位置等少数几个格子发生变化。全屏刷新在低帧率下没问题但如果想追求更流畅的动画就会造成不必要的性能开销。我们可以实现一个简单的局部刷新逻辑只重新绘制发生变化的那几个格子。这需要记录上一帧每个格子的状态是蛇身、食物还是空白并与当前帧比较只更新状态发生变化的格子。这能显著减少绘图指令的数量提高帧率。另一种更通用、在复杂图形中常用的方法是双缓冲。U8g2本身已经在内存中维护了一个显示缓冲区clearBuffer和drawXxx操作的都是这个缓冲区sendBuffer才将其发送到屏幕。这本身就是一种双缓冲避免了屏幕撕裂。我们所说的优化主要是在应用逻辑层减少对缓冲区的全量操作。5.3 按键响应与“死亡回溯”问题在测试中我发现一个常见问题有时感觉按键按了没反应或者蛇突然反向撞死自己。这通常有几个原因消抖参数不当EasyButton库默认的消抖时间是35ms。如果按键质量差抖动时间可能更长可以尝试在begin()后调用setDebounceTime(50)来增加消抖时间。按键扫描频率不足如果主循环因为delay(100)而卡住那么在延迟期间按下的按键就会被错过。使用上面提到的非阻塞定时方法并确保在循环中尽可能频繁地调用btn.read()可以改善这个问题。方向锁逻辑漏洞我们的代码用currentDir ! DOWN来防止向上时按向下键。但如果在极短时间内快速按了相反方向的两个键nextDir可能会被错误覆盖。更严谨的做法是在一个游戏逻辑更新周期内只接受第一个有效的方向输入直到该周期结束。这可以通过一个标志位来实现。5.4 内存管理与数组越界检查我们使用固定大小的数组snakeX[MAX_SNAKE_LENGTH]来存储蛇身。当snakeLength增加到MAX_SNAKE_LENGTH时再吃食物就会导致数组越界程序崩溃。虽然在这个小游戏中达到100格长度很难但好的编程习惯是增加保护if (ateFood) { if (snakeLength MAX_SNAKE_LENGTH) { snakeLength; generateFood(); } else { // 蛇已经达到最大长度可以触发特殊事件比如通关 // 这里简单处理不再增长但继续移动 } }5.5 增加游戏性与功能扩展基础版本完成后可以很容易地添加更多功能让游戏更好玩难度分级通过改变gameUpdateInterval游戏速度来设置简单、普通、困难模式。障碍物在游戏区域中随机生成一些固定的障碍物格子蛇撞上也会死亡。特殊食物生成两种食物一种普通食物加1分和1长度另一种“金色食物”可能加3分但不变长或者提供“无敌”效果几秒钟。音效虽然ESP32-C3没有DAC但可以通过PWM和一个无源蜂鸣器来播放简单的提示音吃食物、撞墙、游戏结束。分数存储利用ESP32-C3的SPIFFS文件系统或Preferences库将最高分highScore保存到闪存中即使断电也不会丢失。6. 硬件组装与外壳设计的实用建议代码调试完毕后可以考虑给它一个“家”做成一个真正的手持设备。6.1 从面包板到PCB开发阶段用面包板和杜邦线没问题但很不牢固稍微一动就可能接触不良。如果想长期把玩建议将电路固化。对于动手能力强的朋友可以自己设计一块简单的PCB将Beetle ESP32-C3、OLED屏接口、按键接口都集成上去甚至加上电池充电管理模块如TP4056和锂电池做成一个独立的便携设备。如果不想自己制板也可以使用**洞洞板万用板**进行焊接这样比面包板稳定得多体积也更小。焊接时注意LED屏和ESP32-C3都是静电敏感器件最好使用防静电烙铁或手腕带。6.2 外壳设计与3D打印一个合适的外壳能极大提升产品的完成度和手感。你可以使用Fusion 360、Tinkercad等免费软件自己建模。设计时需要考虑固定孔位精确测量开发板和屏幕的安装孔位置。按键开孔与按键的尺寸和行程匹配最好能让按键帽稍微凸出外壳。屏幕窗口开一个比显示区域稍大的矩形窗。Type-C接口开孔用于充电和编程。散热虽然本项目功耗不大但ESP32在Wi-Fi工作时会发热外壳可以预留一些透气孔。人体工学考虑手持的舒适度边缘做圆角处理。设计好后可以将模型文件通常是.stl格式发送给3D打印服务商或者用自己的3D打印机打印。材料选择PLA即可便宜且强度足够。6.3 电源管理如果要做成无线的就需要考虑电池供电。Beetle ESP32-C3的工作电压是3.3V可以直接连接一个3.7V的锂电池满电约4.2V。但更推荐的做法是增加一个低压差稳压器LDO或DC-DC降压模块确保给MCU和屏幕提供稳定的3.3V电压。同时可以加入一个带充放电保护功能的充电管理模块这样就能通过Type-C口给电池充电了。整个系统的待机电流和运行电流需要估算以选择合适的电池容量通常几百毫安时就够玩好几个小时了。完成硬件组装后你将获得一个完全属于自己的、可握在掌间的复古贪吃蛇游戏机。从一行行代码到看得见摸得着的实体这种创造带来的满足感正是嵌入式开发最大的乐趣所在。这个项目虽然小但它串联起了嵌入式开发的完整链条硬件选型、电路连接、环境搭建、逻辑编程、调试优化、直至产品化。希望我的这份详细记录能帮你顺利复现或启发你做出属于自己的创意项目。