做保龄球计分这类互动硬件时,最容易被低估的往往不是代码,而是“规则、传感器和状态机”三者怎么咬合的问题。ESP32 上能跑的计分方案很多,但真正把球瓶检测、补投规则、LED 反馈、手机控制串成一套完整系统的教程很少。这篇文章围绕一个 ESP32 保龄球计分系统展开,从硬件选型、环境搭建、计分算法、传感器采集到无线控制扩展,把完整技术方案拆开讲清楚,适合正在做 ESP32 项目、想从点灯进阶到完整交互系统的开发者参考。
1. ESP32 保龄球系统概述与整体架构
1.1 这个项目到底做什么
一个 ESP32 保龄球系统,本质上是把传统的保龄球记分流程自动化。传统场景里,玩家掷球、球瓶倒下、人工计分、重新摆瓶,整个过程依赖人眼判断。而 ESP32 方案要完成的工作包括:
- 检测球是否已经滚出,触发一轮计分流程;
- 识别 10 个球瓶当前是站立还是倒下;
- 根据保龄球官方计分规则计算当前得分;
- 通过灯带、OLED 屏幕或手机 App 实时展示分数;
- 在每一轮结束后提醒玩家复位球瓶,并开启下一轮。
这类系统的应用场景很多,比如桌面迷你保龄球台、商场互动游戏设备、创客教育项目,甚至可以作为体育训练时的辅助计分设备。相比使用摄像头做视觉识别,传感器方案的成本更低、实时性更好,也更适合从 Arduino 或 ESP32 入门的新手学习和二次开发。
1.2 系统整体架构
整个系统可以分成三层来看:
| 层级 | 负责内容 | 典型硬件 |
|---|---|---|
| 传感层 | 检测发球、检测球瓶状态 | 红外避障传感器、倾斜开关、MCP23017 I/O 扩展 |
| 逻辑层 | 计分规则、状态机、数据存储 | ESP32 主控、计分算法代码 |
| 展示与控制层 | 分数显示、灯光反馈、远程控制 | WS2812B 灯带、OLED、手机蓝牙或 WiFi |
这里的关键设计思路是:不要把 10 个球瓶传感器直接接到 ESP32 的 GPIO 上。ESP32 虽然有较多引脚,但接 10 路传感器后可用引脚已经很紧张,而且后续还要接灯带、蜂鸣器、按键、无线模块。更好的做法是使用 MCP23017 这类 I2C I/O 扩展芯片,用两根线扩展出 16 路数字输入,只占用 ESP32 的 SDA 和 SCL。
1.3 为什么选 ESP32 而不是 Arduino Uno 或树莓派
相比 Arduino Uno,ESP32 的优势非常明显:主频更高、内存更大、自带 WiFi 和蓝牙,可以方便地实现手机控制、局域网数据上报。相比树莓派,ESP32 成本更低、启动更快、没有操作系统负担,适合做这种“传感器采集 + 简单展示”的嵌入式系统。另外,ESP32 开发板型号很多,比如常见的 ESP32 Dev Kit、ESP32-S2、ESP32-S3,一般的计分系统使用双核 ESP32 就完全足够。
2. 硬件选型与接线方案
2.1 完整硬件清单
下面是一套完整的硬件清单,按功能模块分组:
| 模块 | 型号/器件 | 数量 | 作用 |
|---|---|---|---|
| 主控 | ESP32 DevKit 开发板 | 1 | 运行计分逻辑与控制逻辑 |
| I/O 扩展 | MCP23017 模块 | 1 | 扩展 16 路数字输入,连接球瓶传感器 |
| 倾斜传感器 | SW-520D 振动/倾斜开关 | 10 | 判断每个球瓶是否倒下 |
| 发球检测 | 红外避障传感器模块 | 1 | 检测球是否通过发球区 |
| 显示 | WS2812B 灯带 或 8x8 LED 点阵 | 若干 | 计分板与灯光反馈 |
| 可选显示 | SSD1306 0.96 英寸 OLED | 1 | 显示帧数、分数、提示信息 |
| 输入 | 按键开关 | 1 | 复位球瓶、开始新一轮 |
| 报警 | 有源蜂鸣器 | 1 | 全中、补中等事件提示音 |
| 电源 | 5V 2A USB 电源 | 1 | 系统供电 |
如果暂时没有 MCP23017,也可以用 CD74HC4067 模拟开关多路复用器,4 个选择引脚加 1 个信号引脚就能读 16 路开关,但代码上需要用查表方式切换通道,比 MCP23017 稍微麻烦一些。
2.2 引脚分配参考
不同 ESP32 开发板的引脚布局有差异,以下是一份经过验证的参考分配方案,实际制作时需要根据开发板丝印调整:
| ESP32 GPIO | 连接对象 |
|---|---|
| GPIO21 | I2C SDA,接 MCP23017 SDA |
| GPIO22 | I2C SCL,接 MCP23017 SCL |
| GPIO4 | 红外避障传感器数字输出 |
| GPIO5 | WS2812B 灯带数据输入 |
| GPIO15 | 复位按键,接一个 10kΩ 上拉电阻到 3.3V |
| GPIO18 | 蜂鸣器正极 |
| GND | 所有模块共地 |
| 3.3V / 5V | 按模块要求分别供电 |
MCP23017 的 A0、A1、A2 地址引脚全部接 GND,I2C 地址为 0x20。球瓶倾斜开关接入 MCP23017 的 GPA0 到 GPA7,以及 GPB0 和 GPB1,正好对应通道 0 到 9。
2.3 接线与供电注意事项
接线时需要注意以下几点:
电源不要走杜邦线串联到底。ESP32、灯带、传感器如果全部从一个 USB 口取电,灯带全亮时电流很容易超过 500mA,导致电压跌落、传感器误判。推荐使用 5V 2A 电源,灯带单独供电,ESP32 和传感器共用一组电源,并且所有模块共地。
倾斜开关需要上拉。SW-520D 这类机械式倾斜开关相当于一个开关量输出,悬空时电平不稳定。MCP23017 内部有可配置的上拉电阻,初始化时通过pullUp()开启即可,不需要每个引脚外接上拉电阻。
红外避障传感器要调节灵敏度。模块上通常有一个蓝色电位器,用来调节检测距离。球从传感器前方经过时输出低电平,如果检测距离太近或者太远,会导致触发失败。
3. 开发环境搭建:Arduino IDE + ESP32
3.1 Arduino IDE 安装 ESP32 支持
Arduino IDE 默认不包含 ESP32 开发板,需要先在“开发板管理器”中安装支持包。
打开 Arduino IDE,进入“文件 -> 首选项”,在“附加开发板管理器网址”中添加:
https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json然后进入“工具 -> 开发板 -> 开发板管理器”,搜索esp32,选择 Espressif Systems 官方提供的esp32包并安装。如果在线下载较慢或失败,可以先把官方打包好的离线安装包下载到本地,解压或放到 Arduino15 对应目录后重新打开 IDE,也能完成安装。
3.2 选择开发板与烧录设置
安装完成后,在“工具 -> 开发板 -> ESP32 Arduino”中选择ESP32 Dev Module。串口选择实际识别的 COM 口或/dev/ttyUSB0。上传速度建议设置为921600,如果烧录不稳定可以降到115200。
如果开发板无法识别,通常是因为 USB 转串口芯片驱动没有安装。使用 CH340 芯片的开发板需要安装 CH340 驱动,使用 CP2102 芯片的则安装 CP210x 驱动。
烧录失败时,可以按住开发板上的 BOOT 按键不放,点击“上传”,等待出现Connecting...提示后再松开 BOOT。ESP32 烧录后出现无法烧录的情况,多数也是这个问题。
3.3 项目所需 Arduino 库
本系统需要安装以下第三方库:
| 库名 | 安装来源 | 用途 |
|---|---|---|
| Adafruit MCP23017 | 库管理器搜索MCP23017 | 操作 I2C I/O 扩展 |
| Adafruit NeoPixel | 库管理器搜索NeoPixel | 控制 WS2812B 灯带 |
| Adafruit SSD1306 | 库管理器搜索SSD1306 | 驱动 OLED 显示 |
| Adafruit GFX | 库管理器搜索GFX | SSD1306 的图形依赖 |
| BluetoothSerial | 无需安装 | ESP32 经典蓝牙串口通信 |
库管理器搜索时要注意,Adafruit 的 MCP23017 库和 Adafruit SSD1306 库都依赖 Adafruit GFX,后者会作为依赖自动安装。
4. 保龄球计分规则与程序设计
4.1 规则梳理
保龄球标准计分规则有一些容易混淆的地方,先把它整理清楚:
- 一局共 10 轮。
- 前 9 轮每轮最多投两次球,第 10 轮比较特殊。
- 如果第一球就打倒全部 10 个球瓶,称为全中,这一轮立即结束,不需要投第二球。
- 如果两球打倒全部球瓶,称为补中。
- 全中的作用是“本次 10 分 + 之后两次投球的被击倒数”。
- 补中的作用是“本次 10 分 + 之后一次投球的被击倒数”。
- 第 10 轮如果投出全中或补中,还可以额外追加投球,最多允许三球。
写计分代码时,最容易出错的就是“当前这一轮还没结束,不能把奖励分算进去”。所以计分函数不能只累加当前数据,必须判断后续投球数据是否已经产生。
4.2 计分引擎设计
把计分逻辑独立成一个函数,输入是每次投球击倒的球瓶数数组,输出是某一轮结束后的累计分数。设计如下:
int computeScoreAt(const int rolls[], int rollCount, int frame) { int score = 0; int roll = 0; for (int f = 0; f < frame; f++) { if (roll + 1 >= rollCount) break; // 数据不足,无法结算 if (rolls[roll] == 10) { // 全中:必须等后面两次投球数据才能累计 if (roll + 2 < rollCount) { score += 10 + rolls[roll + 1] + rolls[roll + 2]; } roll++; } else if (rolls[roll] + rolls[roll + 1] == 10) { // 补中:必须等后面一次投球数据才能累计 if (roll + 2 < rollCount) { score += 10 + rolls[roll + 2]; } roll += 2; } else { score += rolls[roll] + rolls[roll + 1]; roll += 2; } } return score; }rolls数组按顺序保存每一次实际投球的击倒数。比如玩家第 1 轮投出全中,rolls[0] = 10,此时rollCount只有 1,computeScoreAt(..., 1)会因为数据不足而返回 0。等第 2 轮第一球结束后rollCount变为 2,仍然无法补采第 3 个数据,直到第 2 轮第二球结束,rollCount变为 3,第 1 轮分数才能结算为10 + rolls[1] + rolls[2]。
4.3 主循环状态机设计
系统的主循环不能简单地“一检测到球就立即读传感器”。球滚动、碰撞、球瓶摇晃都需要时间,所以这里使用状态机把流程拆开:
| 状态 | 行为 |
|---|---|
| WAIT_BALL | 等待红外传感器检测到球通过 |
| WAIT_SETTLE | 球瓶摇晃等待,1.2 秒后读取球瓶状态 |
| SHOW_RESULT | 显示本轮分数,等待玩家复位球瓶并按键确认 |
这个状态机的好处是把“检测事件”和“处理结果”解耦。如果