项目概述

本项目基于 零知派ESP32,使用 ST7789 240×240 TFT 显示屏和摇杆控制器,实现贪吃蛇游戏的 单人模式 和 双人对战模式

  • 单人模式:经典贪吃蛇,边界死亡、自撞死亡,吃好食物得分并生成坏食物,速度随分数递增
  • 对战模式:双人通过 MQTT 联网对战,穿墙、无自撞、加速/刹车机制,碰撞对方蛇身即获胜

项目亮点

亮点 说明
双人 MQTT 联网对战 基于 EMQX 公共 Broker,无需自建服务器
加速/刹车机制 同方向长按 500ms 加速(前进+缩尾),反方向长按 500ms 刹车(原地缩尾)
串扰消除 摇杆物理串扰通过校准中心值 + 偏差比较自动修正
局部重绘 非全屏刷新,仅擦除/绘制变化区域,提高帧率
ADC1 专用引脚 摇杆接 ADC1 通道,避免与 WiFi 冲突
对战同步 MQTT 消息同步双方蛇身、食物位置,支持碰撞判定

项目难点及解决方案

难点 1:摇杆串扰
现象:推动一个方向时,另一轴也会产生读数偏移,导致方向误判。

解决方案

  1. 启动时采样 20 次取平均,校准摇杆物理中心值 (joyCenterX/Y)
  2. 对角线方向同时触发时,比较两轴偏移量,取偏移更大的方向
  3. 方向阈值相对于校准中心动态计算(±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 硬件清单

1.2 接线方案

1.3 硬件连接图

1.4 实物连接图

二、软件架构设计

2.1 系统初始化

2.2 主循环逻辑

三、代码拆分讲解

3.1 核心常量与配置

3.2 数据结构

3.3 摇杆读取与方向处理

3.4 MQTT 通信

3.5 加速/刹车机制

3.6 食物与坏食物机制

3.7 局部重绘

3.8 蛇身皮肤

3.9 按键消抖

四、操作过程及数据展示

4.1 操作步骤

4.2 演示视频

五、技术原理

5.1 工作原理

5.2 工作模式配置

六、常见问题指引

Q1:摇杆方向不准/串扰严重

Q2:WiFi 连接失败

Q3:MQTT 连不上

Q4:对战双方看不到对方

Q5:屏幕闪烁 / 刷新慢

Q6:加速/刹车无法触发

Q7:对战模式进入后立刻游戏结束

一、硬件系统部分

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
    // 单人模式:禁止反方向转头,不支持加速/刹车
}

串扰消除逻辑

  1. 双轴同时触发时,比较 abs(devX) 和 abs(devY)
  2. 偏移量更大的轴被认为是真实意图
  3. 避免了对角线推动时的方向卡顿和误判

3.4 MQTT 通信

消息格式

POS|<nonce>|<len>|<x1>,<y1>|<x2>,<y2>|...|G5|<fx1>,<fy1>|...|B<n>|<bx1>,<by1>|...
LOSE|<nonce>

每条消息携带消息唯一 ID (msgNonce) 防止处理自己的消息。

接收处理

  1. 解析 POS 消息 → 更新对手蛇身位置、好食物位置、坏食物位置
  2. 检查对手蛇头是否碰撞我方蛇身 → 若碰撞则我方胜利 (gameWon = true)
  3. 解析 LOSE 消息(对方触发) → 显示 “YOU WIN!”
  4. 设置 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 操作步骤

单人模式

  1. 上电 → 屏幕显示菜单
  2. 摇杆 左/右 切换至 Mode: Single
  3. 摇杆 上/下 切换皮肤(SOLID / GRADIENT)
  4. 按下摇杆(SW)开始游戏
  5. 摇杆控制方向(不可反向转头)
  6. 撞墙或自撞 → GAME OVER → 按 SW 返回菜单

对战模式

  1. 两个 ESP32 分别上电
  2. 各自摇杆 左/右 切换至 Mode: Battle
  3. 按 SW → 屏幕显示 Connecting WiFi...
  4. WiFi 连接成功后自动进入游戏
  5. 双方通过 MQTT 同步数据
  6. 同方向按住 ≥ 500ms → 加速(前进+缩尾)
  7. 反方向按住 ≥ 500ms → 刹车(原地缩尾)
  8. 蛇头撞到对方身体 → 对方获胜;头碰头时长者胜

界面说明

菜单

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          │
                                  └─────────────────┘

数据流

  1. 摇杆模拟信号 → ADC1 转换 → readControls() 判断方向/加速/刹车
  2. 方向 + 定时器 → moveSnake() 更新蛇身坐标(含碰撞检测)
  3. 坐标变化 → draw() 局部重绘显示屏
  4. 对战模式下 → publishPosition() 每 60ms 通过 MQTT 广播坐标 + 食物
  5. 同时接收对方坐标 → mqttCallback() 更新对手状态及食物同步
  6. 碰撞检测(头碰身/头碰头)→ 触发 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_THRESHsnake_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 接入同一网络
Logo

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

更多推荐