零知派ESP32--基于HW-504从零搭建双人对战贪吃蛇教程
项目概述
本项目基于 零知派ESP32,使用 ST7789 240×240 TFT 显示屏和摇杆控制器,实现贪吃蛇游戏的 单人模式 和 双人对战模式。
- 单人模式:经典贪吃蛇,边界死亡、自撞死亡,吃好食物得分并生成坏食物,速度随分数递增
- 对战模式:双人通过 MQTT 联网对战,穿墙、无自撞、加速/刹车机制,碰撞对方蛇身即获胜
项目亮点
| 亮点 | 说明 |
|---|---|
| 双人 MQTT 联网对战 | 基于 EMQX 公共 Broker,无需自建服务器 |
| 加速/刹车机制 | 同方向长按 500ms 加速(前进+缩尾),反方向长按 500ms 刹车(原地缩尾) |
| 串扰消除 | 摇杆物理串扰通过校准中心值 + 偏差比较自动修正 |
| 局部重绘 | 非全屏刷新,仅擦除/绘制变化区域,提高帧率 |
| ADC1 专用引脚 | 摇杆接 ADC1 通道,避免与 WiFi 冲突 |
| 对战同步 | MQTT 消息同步双方蛇身、食物位置,支持碰撞判定 |
项目难点及解决方案
难点 1:摇杆串扰
现象:推动一个方向时,另一轴也会产生读数偏移,导致方向误判。
解决方案:
- 启动时采样 20 次取平均,校准摇杆物理中心值 (
joyCenterX/Y) - 对角线方向同时触发时,比较两轴偏移量,取偏移更大的方向
- 方向阈值相对于校准中心动态计算(±1000),消除左右/上下灵敏度差异
难点 2:WiFi 与 ADC 冲突
现象:ESP32 的 ADC2 与 WiFi 共用硬件模块,WiFi 开启后 ADC2 读数异常。
解决方案:摇杆 X/Y 使用 ADC1 引脚(GPIO 33、32),SW 用 GPIO 27(普通 GPIO,INPUT_PULLUP)。
难点 3:加速/刹车防误触
现象:摇杆轻微抖动导致意外触发加速/刹车。
解决方案:
- 使用 500ms 长按确认机制(
boostArmed/brakeArmed标志位) - 激活后持续生效,不再响应反方向等噪音
- 松开摇杆(回到中心区)才取消加速/刹车状态并复位标志
目录
一、硬件系统部分
1.1 硬件清单
| 组件 | 数量 | 说明 |
|---|---|---|
| 零知派ESP32 开发板 | 1 | 写入程序 |
| 零知派ESP32扩展板 | 1 | 方便接线 |
| ST7789 TFT 显示屏 | 1 | 240×240,SPI 接口 |
| 双轴摇杆模块 | 1 | 操控游戏方向 |
| 杜邦线 | 若干 | 母对母 |
| Micro USB 数据线 | 1 | 供电 + 烧录 |
| 第二套相同硬件 | 1 | 双人对战需要两个设备 |
1.2 接线方案
摇杆模块
| 摇杆引脚 | ESP32 GPIO | 说明 |
|---|---|---|
| VRX | GPIO 33 | X 轴(ADC1) |
| VRY | GPIO 32 | Y 轴(ADC1) |
| SW | GPIO 27 | 按键(内置上拉) |
| VCC | 3.3V | 供电 |
| GND | GND | 接地 |
1.3 硬件连接图

注意:GPIO 33、32 是 ADC1 通道,不会与 WiFi 冲突。
GPIO 27 作为 SW 输入,必须使用INPUT_PULLUP(摇杆按键按下 = LOW)。
1.4 实物连接图

二、软件架构设计
2.1 系统初始化
setup()
├── Serial.begin(115200)
├── tft.init()
├── tft.setRotation(3)
├── tft.fillScreen(BG_COLOR)
├── drawBorder() // 蓝色边框
├── pinMode(JOY_VRX, INPUT)
├── pinMode(JOY_VRY, INPUT)
├── pinMode(JOY_SW, INPUT_PULLUP)
├── 随机种子生成 (analogRead(34)×20 + micros())
├── 摇杆中心校准 (20 次采样平均)
├── msgNonce = esp_random() // 消息唯一 ID
├── mqttClient.setServer(...)
├── mqttClient.setCallback(...)
├── mqttClient.setKeepAlive(60)
├── wifiClient.setTimeout(800)
└── drawMenu() // 显示菜单
2.2 主循环逻辑
loop()
├── MQTT 处理(对战模式每 50ms)
│ ├── reconnectMQTT() // 每 5s 尝试重连
│ └── mqttClient.loop() // 处理回调
│
└── switch(phase)
├── PHASE_MENU → handleMenu()
│ ├── 摇杆左/右切换模式 (Single / Battle)
│ ├── 摇杆上/下切换皮肤 (SOLID / GRADIENT)
│ └── SW 确认
│ ├── 对战模式:phase = PHASE_CONNECT → setupWiFi()
│ └── 单人模式:phase = PHASE_PLAY → initGame()
│
├── PHASE_CONNECT → handleConnecting()
│ ├── 每 3s 刷新 "Connecting WiFi..." 文字
│ └── WiFi 连接成功 → phase = PHASE_PLAY → initGame()
│
├── PHASE_PLAY → handleGame()
│ ├── readControls() // 读取摇杆 → 方向/加速/刹车
│ ├── 检查移动计时器
│ │ ├── 刹车模式:interval = base × 3, applyBrake()
│ │ ├── 加速模式:interval = base, applyBoost()
│ │ └── 常规移动:interval = base, moveSnake()
│ ├── draw() // 局部重绘
│ └── publishPosition() // 对战模式每 60ms 广播
│
└── PHASE_OVER → handleGameOver()
└── SW 确认 → 返回菜单
三、代码拆分讲解
3.1 核心常量与配置
#define GRID_W 23 // 网格宽度(格数)
#define GRID_H 23 // 网格高度
#define CELL_SIZE 10 // 每格像素
#define BORDER_WIDTH 5 // 边框宽度
#define GOOD_FOOD_COUNT 5 // 好食物数量
#define MAX_BAD_FOOD 20 // 最大坏食物数量
#define JOY_THRESH 1000 // 摇杆方向判定阈值
#define MENU_THRESH 800 // 菜单选择阈值(更灵敏)
#define HOLD_MS 500 // 长按确认时间
#define SW_DEBOUNCE_MS 50 // 按键消抖时间
#define MIN_SNAKE_LEN 2 // 最小蛇长度(刹车/加速下限)
网格 23×23,每格 10px,加上 5px 边框正好填满 240×240 屏幕。
3.2 数据结构
enum Dir { STOP, UP, DOWN, LEFT, RIGHT };
Dir dir = STOP, nextDir = STOP; // 当前方向 / 缓冲方向
int joyCenterX = 2048, joyCenterY = 2048; // 摇杆校准中心
uint32_t msgNonce; // 消息唯一 ID(替代 playerID)
bool battleMode; // 对战模式标志
uint8_t snakeX[MAX_LEN], snakeY[MAX_LEN]; // 蛇身坐标 (0=蛇头)
uint8_t oppX[MAX_LEN], oppY[MAX_LEN]; // 对手蛇身坐标
int oppLen, prevOppLen; // 对手长度 & 上一帧长度(擦除用)
bool oppActive; // 对手是否活跃
uint8_t foodX[GOOD_FOOD_COUNT], foodY[GOOD_FOOD_COUNT]; // 好食物
uint8_t badFoodX[MAX_BAD_FOOD], badFoodY[MAX_BAD_FOOD]; // 坏食物
int badFoodCount;
int prevTailX, prevTailY; // 上一帧蛇尾(擦除用)
int extraEraseX, extraEraseY; // 额外擦除坐标(坏食物/加速缩尾)
bool justAte; // 刚吃食物(尾部不擦除)
bool boostMode, brakeMode; // 加速/刹车状态
bool boostArmed, brakeArmed; // 长按预激活标志
unsigned long boostStart, brakeStart;
关键设计:
nextDir和dir分离,使方向变更与移动解耦,防止一帧内多次转向prevTailX/Y记录上一帧蛇尾坐标,仅擦除变化区域extraEraseX/Y处理加速缩尾和坏食物移除时的额外擦除boostArmed/brakeArmed+HOLD_MS实现 500ms 长按确认
3.3 摇杆读取与方向处理
void readControls() {
int devX = vrx - joyCenterX;
int devY = vry - joyCenterY;
// 松杆 → 取消所有状态
if (!left && !right && !up && !down) {
boostMode = brakeMode = false;
boostArmed = brakeArmed = false;
return;
}
// 对角线串扰消除:比较 absDevX vs absDevY,取大者
Dir joyDir = (absDevX > absDevY) ? 横向 : 纵向;
// 对战模式:
// 同方向 → 预激活加速,500ms 后 boostMode=true
// 反方向 → 预激活刹车,500ms 后 brakeMode=true
// 单人模式:禁止反方向转头,不支持加速/刹车
}
串扰消除逻辑:
- 双轴同时触发时,比较
abs(devX)和abs(devY) - 偏移量更大的轴被认为是真实意图
- 避免了对角线推动时的方向卡顿和误判
3.4 MQTT 通信
消息格式:
POS|<nonce>|<len>|<x1>,<y1>|<x2>,<y2>|...|G5|<fx1>,<fy1>|...|B<n>|<bx1>,<by1>|...
LOSE|<nonce>
每条消息携带消息唯一 ID (msgNonce) 防止处理自己的消息。
接收处理:
- 解析
POS消息 → 更新对手蛇身位置、好食物位置、坏食物位置 - 检查对手蛇头是否碰撞我方蛇身 → 若碰撞则我方胜利 (
gameWon = true) - 解析
LOSE消息(对方触发) → 显示 “YOU WIN!” - 设置
redrawPending = true触发全量重绘
发送处理 (publishPosition, 第 264-286 行):
- 每 60ms 广播一次(
MQTT_PUB_INTERVAL) - 最多发送 100 节蛇身
- 同时发送食物位置,保证双方食物一致
碰撞判定 (moveSnake, 第 729-748 行):
- 我方蛇头 vs 对手蛇身 → 若 i==0(头碰头),比较长度,长者胜;否则撞击方失败
- 失败方自动发送
LOSE消息通知对方
3.5 加速/刹车机制
同方向按住 ≥ 500ms → boostMode = true
├── applyBoost(): 正常前进 + 尾部减一节
├── 速度 = baseInterval (150ms,最快)
└── 释放摇杆 → 取消
反方向按住 ≥ 500ms → brakeMode = true
├── applyBrake(): 头部暂停 + 尾部减一节 (原地缩回)
├── 速度 = baseInterval × 3 (450ms,最慢)
└── 释放摇杆 → 取消
单人模式下不启用加速/刹车,boostMode 和 brakeMode 始终为 false。
3.6 食物与坏食物机制
好食物:场上固定 5 个,吃掉后:
- 蛇身 +1(继承上一帧尾部位置)
- 得分 +1
- 单人模式:移动速度加快(
moveInterval -= 3,最低 60ms) - 单人模式:生成一个新坏食物(不超过 20 个上限)
- 立即在空位生成新好食物
坏食物:
- 单人模式:初始 0 个,每次吃好食物或坏食物时可能增加(不超过 20)
- 对战模式:初始 3 个,固定数量(被吃后不会补充)
- 吃到坏食物:蛇身 -1(不低于 2),移除该坏食物
3.7 局部重绘
单人模式:
1. 若刚吃掉食物 (justAte=true),保留尾部(蛇已变长)
否则擦除上一帧蛇尾 (prevTailX, prevTailY)
2. 擦除 extraEraseX/Y(坏食物移除或加速缩尾需要额外擦除)
3. 绘制蛇身 (1..snakeLen-1)
4. 绘制蛇头(含白色眼睛)
5. 绘制所有食物
6. 更新记分
对战模式:
1. 同上擦除蛇尾和额外擦除
2. 擦除上一帧对手蛇身 (prevOppX/Y)
3. 绘制我方蛇身 + 蛇头
4. 绘制对手蛇身(橙色身体 + 红色头)
5. 重绘蓝色边框(对手可能覆盖边框)
6. 绘制所有食物
7. 保存当前对手坐标到 prevOpp,供下一帧擦除
3.8 蛇身皮肤
SKIN_SOLID:蛇头 TFT_GREEN,身体 TFT_DARKGREEN
SKIN_GRADIENT:蛇头绿色 → 身体渐变为蓝色 (RGB: 0, 255→0, 0→255)
3.9 按键消抖
bool isSWPressed() {
if (digitalRead(JOY_SW) == LOW) {
if (!swWaiting) { swWaiting = true; swPressTime = now; }
} else {
if (swWaiting && now - swPressTime >= SW_DEBOUNCE_MS) {
swWaiting = false;
return true; // 上升沿触发,消抖通过
}
swWaiting = false;
}
return false;
}
采用 上升沿触发 + 消抖延时:按下时记录时间,松开时检查是否超过 50ms,确保一次完整的按下-松开动作才触发。
四、操作过程及数据展示
4.1 操作步骤
单人模式
- 上电 → 屏幕显示菜单
- 摇杆 左/右 切换至
Mode: Single - 摇杆 上/下 切换皮肤(SOLID / GRADIENT)
- 按下摇杆(SW)开始游戏
- 摇杆控制方向(不可反向转头)
- 撞墙或自撞 → GAME OVER → 按 SW 返回菜单
对战模式
- 两个 ESP32 分别上电
- 各自摇杆 左/右 切换至
Mode: Battle - 按 SW → 屏幕显示
Connecting WiFi... - WiFi 连接成功后自动进入游戏
- 双方通过 MQTT 同步数据
- 同方向按住 ≥ 500ms → 加速(前进+缩尾)
- 反方向按住 ≥ 500ms → 刹车(原地缩尾)
- 蛇头撞到对方身体 → 对方获胜;头碰头时长者胜
界面说明
菜单:
SNAKE
Mode: Battle ← L/R 切换
Skin: SOLID ← U/D 切换
< L/R: Mode U/D: Skin >
Press SW to start
游戏界面(单人):
Score: 5 ← 蓝色边框上的记分
┌─────────────────────┐
│ · · · · · · │
│ ■■■■ ● ●● │ ← 绿色蛇身
│ ■■■■ ● ● ●● │
│ ● ● ● ● · │ ← 红色:好食物
│ · · ● ● ● │ ← 黑色:坏食物
└─────────────────────┘
游戏界面(对战):
Sc:3 OPP ← 对战模式显示对手状态
┌─────────────────────┐
│ · · · · · · │
│ ■■■■ ● ●● │ ← 绿色:我方
│ ■■■■ ● ● ●● │ ← 灰色:对手身体
│ ● ● ● ● · │ ← 黑色:对手蛇头
│ · · ● ● ● │ ← 红色方块:好食物
└─────────────────────┘
4.2 演示视频
零知派ESP32--基于HW-504双人对战贪吃蛇教程
五、技术原理
5.1 工作原理
┌───────────┐ 摇杆模拟信号 ┌─────────┐
│ 摇杆模块 │─────────────────→│ ESP32 │
│ (VRX/VRY) │ ADC1 读取 │ ADC1 │
└───────────┘ │ GPIO33/32│
│ │
┌───────────┐ 数字信号 │ GPIO27 │
│ 摇杆按键 │─────────────────→│ (INPUT │
│ (SW) │ 按下 = LOW │ _PULLUP)│
└───────────┘ │ │
│ 逻辑处理 ┌────────────┐
┌───────────┐ SPI 总线 │ ←──────────→ │ TFT 显示屏 │
│ ST7789 │←─────────────────│ drawBorder/ │ 240×240 │
│ 显示屏 │ GPIO 18/23/4/13/15│ drawSnake/ │ │
└───────────┘ │ drawFood └────────────┘
│ │
│ WiFi + MQTT ┌────────────┐
│ ←──────────→ │ EMQX 公共 │
│ broker.emqx.io│ MQTT Broker│
│ 1883 │ │
│ topic: ovoreceive │
└─────────────────┘
数据流:
- 摇杆模拟信号 → ADC1 转换 →
readControls()判断方向/加速/刹车 - 方向 + 定时器 →
moveSnake()更新蛇身坐标(含碰撞检测) - 坐标变化 →
draw()局部重绘显示屏 - 对战模式下 →
publishPosition()每 60ms 通过 MQTT 广播坐标 + 食物 - 同时接收对方坐标 →
mqttCallback()更新对手状态及食物同步 - 碰撞检测(头碰身/头碰头)→ 触发
LOSE消息 → 双方显示游戏结束
5.2 工作模式配置
| 配置项 | 单人模式 | 对战模式 |
|---|---|---|
| 边界规则 | 死亡 | 穿墙(wrap-around) |
| 自撞规则 | 死亡 | 忽略 |
| 摇杆反方向 | 忽略 | 刹车(500ms 长按) |
| 同方向长按 | 忽略 | 加速(500ms 长按) |
| 坏食物初始 | 0 | 3 |
| 坏食物补充 | 每次吃食物 +1(上限 20) | 被吃后不补充 |
| 基础速度 | 150ms,每吃一食物 -3ms(最低 60ms) | 固定 150ms |
| 加速速度 | — | 150ms(前进+缩尾) |
| 刹车速度 | — | 450ms(原地缩尾) |
| 连接需求 | 无 | WiFi + MQTT |
| 运动检测 | 仅判断方向 | 额外检测 boost/brake 状态 |
| 碰撞判定 | 撞墙 + 自撞 | 撞对方蛇身 / 头碰头(长者胜) |
六、常见问题指引
Q1:摇杆方向不准/串扰严重
代码已在启动时自动校准摇杆中心值。如果仍有问题:
- 确保启动时摇杆处于自由状态(无人手触碰)
- 检查 VRX/VRY 是否接在 ADC1 引脚(GPIO 33、32)
- 阈值 1000 过于灵敏?可适当增大
JOY_THRESH(snake_game.ino:40)
Q2:WiFi 连接失败
- 确认 Wi-Fi 名称和密码正确(
snake_game.ino:7-8) - ESP32 仅支持 2.4GHz 频段
- 如果路由器在中国,某些公共 Wi-Fi 可能要求 Portal 认证
Q3:MQTT 连不上
- 检查网络连通性:ESP32 能否连接
broker.emqx.io - 查看串口输出:
Serial.begin(115200)已启用 - 某些网络环境防火墙屏蔽 1883 端口,可尝试切换 WebSocket(需修改代码)
Q4:对战双方看不到对方
- 确认两个 ESP32 连接到同一 MQTT Broker
- 检查 Topic 一致:均为
ovoreceive - 检查
msgNonce不同(系统已用esp_random()随机生成) - MQTT 消息有 60ms 节流,位置更新最多每秒 16 次
Q5:屏幕闪烁 / 刷新慢
- 使用局部重绘(已实现),避免全屏
fillScreen - SPI 频率设置为 40MHz,理论上足够
Q6:加速/刹车无法触发
- 需要 同方向(加速)或反方向(刹车)按住 ≥ 500ms(不是快速拨动)
- 需要 摇杆偏离中心区(阈值 ±1000)
- 加速/刹车激活后摇杆抖动不会取消,必须释放回中心区
- 单人模式不启用加速/刹车
Q7:对战模式进入后立刻游戏结束
- 检查双方是否连接到同一 MQTT 主题
- 检查
msgNonce是否相同(极小概率,重启即可解决) - 确认代码中 SSID 和密码正确,且 ESP32 接入同一网络
更多推荐


所有评论(0)