ESP32保龄球计分系统实战:状态机建模与Web交互
2026/8/30 6:14:05 网站建设 项目流程

如果你最近在逛开源硬件社区,可能会看到不少人晒出“ESP32 Bowling System”相关的技术拆解。保龄球计分系统听起来像商场里那种庞大设备,但实际上,用一块几十块钱的 ESP32 开发板,加上几组传感器和一个显示屏幕,就能做出一台桌面级迷你保龄球机,并且能完成自动击倒检测、实时计分、回合切换、成绩展示这一整条完整流程。

这不是一个只能“点个灯”的入门练手项目。它真正串起来的能力包括:多路信号采集、边缘状态判断、经典体育规则建模、本地 Web 交互,以及嵌入式系统的容错处理。换句话说,它是一项“麻雀虽小,五脏俱全”的嵌入式系统实战,比单纯跑一个 Hello World 或者读一个温湿度传感器,能带给你的收获要大得多。

这篇文章会从为什么选择 ESP32 开始,依次拆解硬件方案、系统架构、计分规则建模、代码实现、验证方法、常见坑点,以及工程化建议。考虑到目前网上对这个项目的资料比较零散,我用一套可落地的分层方案把流程串起来。即使你现在手上没有完整的保龄球道具,也可以用面包板 + 模拟按键先跑通软件逻辑,后续再替换成真实传感器。

1. 这篇文章真正要解决的问题

很多教程都在讲 ESP32 怎么连 WiFi、怎么读传感器、怎么控制电机,但当你真正想做一个“有点完整功能”的小项目时,会卡在几个地方。

第一个痛点是状态管理。保龄球游戏不是简单累加击倒数,它涉及轮次、每轮投球次数、补中、全中、第 10 轮的特殊规则。如果代码里全是 if-else 嵌套,写到最后自己都会绕晕。第二个痛点是传感器信号不是理想的 0/1,红外对管可能被环境光干扰,微动开关可能有抖动,误判漏判都会导致计分错乱。第三个痛点是系统交互,一个嵌入式设备需要在本地提供实时比分展示,不能总靠串口监视器看结果。

这篇文章要解决的问题,就是帮你把这些零散的知识点组合成一个闭环项目。读完以后,你应该能独立完成这样几件事:

  • 理解保龄球计分规则的状态流转,而不是只写一个加法器。
  • 设计一套适配 ESP32 GPIO 资源的传感器输入方案。
  • 在 Arduino 框架下编写一套可复用的保龄球计分核心类。
  • 通过本地 Web 页面实时查看比赛状态。
  • 遇到信号抖动、串口打印乱码、烧录失败时,知道排查顺序。

先说判断结论:ESP32 Bowling System 真正值得学习的地方,不是“保龄球”这个业务本身,而是它对状态机建模和端到端系统设计的要求。很多开发者把精力花在了酷炫的外设上,却忽略了游戏规则背后的状态流转,这是本末倒置的。

2. 为什么要用 ESP32 做保龄球计分系统

2.1 传统方案的问题

如果回到十年前,做一个自动化保龄球计分系统,通常的选型是 Arduino Uno + 一堆传感器 + 数码管/LCD 屏幕。Arduino Uno 在这个场景下有三个明显短板。

第一,MCU 资源紧张。Uno 的 SRAM 只有 2KB,Flash 是 32KB。计分逻辑本身不复杂,但一旦要处理 Web 展示、LCD 刷新、传感器扫描并行运行,内存就捉襟见肘。

第二,网络能力需要外接模块。ESP8266 可以补上 WiFi,但需要额外调试 AT 指令,传输链路多了一层不稳定因素。更麻烦的是,Arduino Uno + ESP8266 的组合,两套固件、两套串口逻辑,调试成本直接翻倍。

第三,IO 口数量限制。如果保龄球道上有 8 个瓶位传感器,加上重置按钮、模式切换、LCD/OLED 显示接口,Uno 的 GPIO 要精打细算,甚至需要扩展 I2C GPIO 模块。

2.2 ESP32 的核心优势

ESP32 之所以适合这个项目,是因为它在单个芯片上集成了一组非常均衡的能力。

首先是性能。ESP32 采用双核 Xtensa LX6 处理器,主频最高可达 240MHz。计分计算本身占用很低,但双核能力给了系统设计更多弹性。比如一个核做传感器轮询和计分状态更新,另一个核跑 Web Server,二者互不干扰。

其次是网络。ESP32 原生支持 WiFi 和蓝牙。这意味着计分结果可以直接通过局域网推送到浏览器页面,不需要额外接线,也不需要定制上位机。用手机、平板、电脑打开浏览器就能看到实时比分,这对桌面保龄球机来说体验提升明显。

再看外设接口。ESP32 的 GPIO 数量比大多数 Arduino 开发板丰富,并且支持 ADC、I2C、SPI、UART、PWM 等多路复用。一组传感器 + LCD + 按键 + 指示灯,一块板子基本搞定,不用焊接一堆转接板。

还有一点容易被忽略:ESP32 的 ADC 精度虽然不如独立 ADC 芯片,但对于模拟量传感器信号(例如光线强度变化、压力传感器输出)已经足够。而且多通道 ADC 可以搭配模拟多路开关,扩展出更多输入源。

2.3 ESP32 项目开发方式选哪个

ESP32 的主流开发方式有 ESP-IDF、Arduino 框架和 PlatformIO。保龄球计分系统这种规模的项目,我更推荐 Arduino 框架 + PlatformIO 工作流。

ESP-IDF 是乐鑫官方 SDK,功能强大、组件完整,但学习曲线比较陡。对一个桌面小项目来说,直接用 ESP-IDF 管理 WiFi、HTTP Server、GPIO 中断,代码量会比 Arduino 框架多不少。Arduino 框架把底层细节封装得很友好,WiFi 连接、Web Server、传感器读取都有现成库。PlatformIO 则解决了 Arduino IDE 在工程管理、依赖库、多文件结构上的短板。

一句话总结:如果你想快速跑通业务逻辑,把精力放在计分和交互上,Arduino 框架 + PlatformIO 是效率最高的组合。

对比维度Arduino IDEPlatformIOESP-IDF
上手难度中低
项目管理能力
第三方库管理一般方便一般
适合项目规模小实验中小型完整项目复杂产品/生产固件

所以本文后续示例均基于 Arduino 框架语法,但建议你在本地使用 PlatformIO 建工程。如果暂时不熟悉 PlatformIO,直接用 Arduino IDE 复制代码也能跑通,只是文件组织会散一些。

3. Bowling System 的系统架构与核心设计

3.1 系统整体分层

在写代码之前,先想清楚整个系统分成几层,这比直接动手写loop()重要得多。

ESP32 Bowling System 建议分成四层:

  • 感知层:负责检测球道上球瓶的状态变化,包括光电传感器、微动开关或霍尔传感器。每个瓶位对应一个输入信号。
  • 控制层:运行在 ESP32 上,负责轮询传感器、信号滤波、判断投球状态(就绪、滚动中、落定)、更新比赛状态。
  • 规则层:实现保龄球计分规则,输入是“每次投球击倒的数量”,输出是“当前局各轮分数和总分”。
  • 展示层:把比分和比赛状态推送到 LCD/OLED、浏览器页面、或者通过串口输出,供调试和交互。

这里容易犯的一个错误是,把感知层和规则层耦合在一起。如果传感器数据直接在计分函数里处理,后面想更换传感器类型,就必须改动计分逻辑。更好的做法是,感知层负责产出“本次投球击倒几个瓶”,规则层只接收数字,不管这个数字是红外传感器算出来的,还是手动按钮输入的。这样系统替换成本低,而且方便先用按键模拟传感器进行开发测试。

3.2 状态机设计

这个项目的灵魂不是代码语法,而是状态机。保龄球计分过程不是一个简单的累加过程,它必须知道当前处于哪一轮、本轮第几次投球、上一轮是否全中或补中。

我设计了一个精简的状态机:

  • WAIT_BALL:等待玩家放球或触发开始。
  • BALL_ROLLING:球已投出,等待传感器变化。
  • SCAN_SETTLE:球停止滚动,传感器状态稳定,开始判断。
  • CALC_SCORE:根据击倒数更新计分规则。
  • SHOW_RESULT:刷新展示层,等待下一球。
  • GAME_OVER:10 轮结束,展示总成绩。

这个状态机的核心价值是让代码主循环非常干净,每个状态只做该做的事。传感器抖动不会在投球过程中导致误判,因为BALL_ROLLING状态下系统不会立即采数,而是等待一个稳定窗口。

3.3 保龄球计分规则回顾

很多没有打过保龄球的开发者会把计分想得太简单。需要先明确规则,否则代码一定会出错。

标准保龄球共 10 轮(Frame),每轮默认可以投两次球,目标是把 10 个球瓶全部击倒。

  • 普通命中(Open):两球击倒总数不满 10,本轮得分就是击倒数量。
  • 补中(Spare):两球击倒总数正好为 10,本轮基础分 10 分,再加上下一球击倒数量作为奖励。
  • 全中(Strike):第一球就击倒 10 个球瓶,本轮基础分 10 分,再加上下两球击倒数量作为奖励。
  • 第 10 轮特殊规则:如果第 10 轮第一次投球全中,或两球补中,则追加一次投球机会,用来计算奖励分。

这意味着计分不能逐轮立刻结算,因为全中和补中的奖励依赖于后续投球。一个常见的实现方案是:先记录每一投的击倒数,然后等数据齐全后再从第 1 轮开始逐轮计算总分。这个方案逻辑清晰,调试方便,不会因为中间状态复杂而出错。

下面用一个表格展示标准计分场景:

轮次投球记录本轮说明本轮累计总分
110(Strike)待定,等后两球奖励暂时无法确定
27, 29 分 + 第1轮奖励 = 1928
38, 2(Spare)待定,等下一球奖励暂时无法确定
410(Strike)第3轮补中奖励10 + 第3轮10 = 2048

只有把规则层独立出来,并且采用“先记录、再结算”的策略,才能稳妥地处理这些情况。

4. 硬件方案与引脚分配

4.1 推荐硬件清单

为了兼顾“能跑通”和“接近真实场景”,我推荐以下硬件组合:

  • ESP32 DevKitC 或 ESP32-WROOM-32 开发板一块。
  • 8 路红外对管模块,用于检测 8 个球瓶位是否有瓶倒下。
  • 一个微动开关或光电开关,用于检测球是否已经滚出。
  • 一个 OLED 显示屏(I2C 接口,如 SSD1306)。
  • 几个按键,用于手动模拟投球和重置。
  • 面包板、杜邦线若干。

如果没有真实球道,可以先用 8 个按键代替 8 路传感器。按压按键代表某个位置的瓶子被击倒,这样就能先验证规则层逻辑,再逐步替换成真实传感器。

4.2 GPIO 分配建议

这里给出一个参考分配方案。GPIO 分配可以根据实际手上模块灵活调整,但要注意避开 ESP32 的下载启动约束引脚。

功能GPIO说明
瓶位传感器 1-4GPIO 32、33、25、26使用 ADC 输入引脚,兼容数字读取
瓶位传感器 5-8GPIO 27、14、12、13输入模式,启用内部上拉
球通过检测GPIO 4输入模式,检测球滚出
OLED SDAGPIO 21I2C 数据线
OLED SCLGPIO 22I2C 时钟线
重置按钮GPIO 0注意 GPIO 0 默认是启动模式引脚,建议拆到 GPIO 5
手动投球按钮GPIO 15调试模式使用

特别提醒:GPIO 6 到 GPIO 11 在大多数 ESP32 模组上连接着 SPI Flash,不能随便当普通 IO 用。GPIO 0 和 GPIO 2 会影响烧录启动,尽量避开或者做好外部电路设计。很多新手把传感器接在 GPIO 6 上,发现死活读不到数据,查到最后才发现是引脚选型问题。

4.3 传感器信号处理

真实场景下,红外对管检测球瓶时,信号不会非常干净。球瓶倒下会瞬间改变遮光状态,但环境光、机械振动、传感器响应延迟都会产生毛刺信号。

建议采用两种处理手段:

  • 硬件滤波:在每个传感器输出引脚对地并联一个 0.1uF 陶瓷电容,构成简单的 RC 低通滤波,能有效减少高频抖动。
  • 软件滤波:连续读取多次,只有连续 N 次读到相同电平才认为状态稳定。例如连续读 5 次,至少有 4 次为高电平,才判定为“有瓶”。

不要一上来就追求复杂的卡尔曼滤波或者滑动窗口算法。对于 8 路传感器,简单的计数滤波已经足够。真正容易出问题的地方是,在球滚动过程中就进行了状态更新,应该等到球完全停下、传感器稳定后再触发计分。

5. 环境准备与开发工具链

5.1 开发环境安装

本文示例基于 Arduino 框架,因此需要先安装 Arduino IDE 或 PlatformIO。如果使用 PlatformIO,在 VS Code 扩展市场搜索 PlatformIO IDE 并安装即可;如果使用 Arduino IDE,则需要在“开发板管理器”中安装 esp32 支持包。

国内网络环境下,Arduino IDE 下载 ESP32 包经常失败。一个稳妥的做法是使用乐鑫提供的离线安装包,或者通过 PlatformIO 的包管理机制下载。PlatformIO 在首次编译时会自动下载 espressif32 平台文件,并且支持断点续传,比 Arduino IDE 的在线安装体验好很多。

安装完成后,建议先运行一个最简单的 Blink 例程,确认烧录路径、串口号、板级配置都正确。很多后续出现的问题,其实都出在环境没通这一层。

5.2 需要用到的第三方库

根据上面的设计,需要安装以下库:

  • U8g2Adafruit_SSD1306用于 OLED 显示。
  • WebServer.h(ESP32 内置)用于本地 Web 展示。
  • WiFi.h(ESP32 内置)用于网络连接。
  • ArduinoJson(可选),如果 Web 端需要结构化 JSON 数据。

依赖库尽量保持精简。ArduinoJson 在解析配置时会方便一些,但如果只是从 ESP32 往浏览器推数据,手写拼接 JSON 字符串或使用简单的字符串响应也能完成,可以减少一个不稳定因素。

6. 核心代码实现:从传感器到计分规则

接下来进入本文最核心的部分。为了让代码可以直接复用,我按模块拆开说明。

6.1 瓶位状态扫描模块

这个模块的目标是:扫描 8 个瓶位,返回一个byte变量,每一位代表一个位置是否有球瓶。如果某位为 0,说明该位置球瓶已经倒下。这个结果就是计分规则层的直接输入。

// 文件路径:include/PinSensor.h #ifndef PIN_SENSOR_H #define PIN_SENSOR_H #include <Arduino.h> class PinSensor { public: PinSensor(const uint8_t* pins, uint8_t count); void begin(); // 读取当前所有瓶位状态,返回每个位置是否存在球瓶 // bit=1 表示有瓶,bit=0 表示已被击倒 uint8_t scanStandingPins(); int getStandingCount(); private: const uint8_t* _pins; uint8_t _count; bool _readPinStable(uint8_t pin, uint8_t stableTimes); }; #endif
// 文件路径:src/PinSensor.cpp #include "PinSensor.h" PinSensor::PinSensor(const uint8_t* pins, uint8_t count) { _pins = pins; _count = count; } void PinSensor::begin() { for (uint8_t i = 0; i < _count; i++) { pinMode(_pins[i], INPUT_PULLUP); } } bool PinSensor::_readPinStable(uint8_t pin, uint8_t stableTimes) { uint8_t highCount = 0; uint8_t lowCount = 0; for (uint8_t i = 0; i < stableTimes; i++) { if (digitalRead(pin) == HIGH) { highCount++; } else { lowCount++; } delay(1); } return highCount > lowCount; } uint8_t PinSensor::scanStandingPins() { uint8_t result = 0; for (uint8_t i = 0; i < _count; i++) { if (_readPinStable(_pins[i], 5)) { result |= (1 << i); } } return result; } int PinSensor::getStandingCount() { uint8_t state = scanStandingPins(); int count = 0; for (uint8_t i = 0; i < _count; i++) { if (state & (1 << i)) { count++; } } return count; }

这里的设计要点在于,一次scanStandingPins()返回的不是传感器电平的瞬时状态,而是经过 5 次采样滤波后的稳定结果。每个引脚连续采 5 次,按多数票决定最终状态,能有效过滤机械抖动和环境光干扰。

在测试阶段,可以直接用 8 个按键模拟这 8 路输入,按键按下表示对应位置球瓶被击倒。

6.2 保龄球计分核心类

这是整个系统的业务核心。为了避免在 Arduino 主程序中堆砌逻辑,我把计分规则封装成BowlingScore类。

实现思路是先用一个数组记录每一投的击倒数,然后每次需要获取当前总分时,从第 1 轮开始完整结算一遍。这种方式没有复杂的前向依赖,代码可读性和可调试性都很好。

// 文件路径:include/BowlingScore.h #ifndef BOWLING_SCORE_H #define BOWLING_SCORE_H #include <Arduino.h> #define MAX_FRAMES 10 #define MAX_THROWS 21 class BowlingScore { public: BowlingScore(); void reset(); // 记录一次投球击倒的数量 // 返回当前处于本轮的第几次投球(1 或 2),以及是否需要切换状态 bool recordThrow(uint8_t knockedDown); int getCurrentTotalScore(); int getFrameScore(uint8_t frameIndex); bool isGameOver(); uint8_t getCurrentFrame(); uint8_t getThrowInFrame(); uint8_t getPinsRemaining(); private: uint8_t _throws[MAX_THROWS]; uint8_t _throwCount; bool _isStrike(uint8_t frameIndex); bool _isSpare(uint8_t frameIndex); bool _isFrameComplete(uint8_t frameIndex); void _debugPrint(); }; #endif
// 文件路径:src/BowlingScore.cpp #include "BowlingScore.h" BowlingScore::BowlingScore() { reset(); } void BowlingScore::reset() { memset(_throws, 0, sizeof(_throws)); _throwCount = 0; } bool BowlingScore::recordThrow(uint8_t knockedDown) { if (_throwCount >= MAX_THROWS) { return true; } _throws[_throwCount++] = knockedDown; return isGameOver(); } bool BowlingScore::isGameOver() { if (_throwCount < 20) { return false; } // 20 次投球后,如果第 10 轮不是全中或补中,比赛结束 uint8_t frame = 9; // 第 10 轮索引 uint8_t first = _throws[18]; uint8_t second = _throws[19]; bool hasBonus = (first == 10 || (first + second == 10)); if (hasBonus && _throwCount < 21) { return false; } return true; } uint8_t BowlingScore::getCurrentFrame() { uint8_t frame = 0; uint8_t throwIdx = 0; for (frame = 0; frame < 10; frame++) { if (throwIdx >= _throwCount) { break; } if (_throws[throwIdx] == 10) { throwIdx++; } else { if (throwIdx + 1 >= _throwCount) { // 本轮第二球还没投 break; } throwIdx += 2; } } return frame + 1; } uint8_t BowlingScore::getThrowInFrame() { uint8_t frameIdx = getCurrentFrame() - 1; uint8_t throwIdx = 0; for (uint8_t i = 0; i < frameIdx; i++) { if (_throws[throwIdx] == 10) { throwIdx++; } else { throwIdx += 2; } } if (frameIdx == 9) { uint8_t cnt = 0; while (throwIdx < _throwCount && cnt < 3) { throwIdx++; cnt++; } return cnt + 1; } if (throwIdx < _throwCount && _throws[throwIdx] == 10) { return 1; } if (throwIdx < _throwCount) { return 2; } return 1; } uint8_t BowlingScore::getPinsRemaining() { uint8_t frameIdx = getCurrentFrame() - 1; uint8_t throwIdx = 0; for (uint8_t i = 0; i < frameIdx; i++) { if (_throws[throwIdx] == 10) { throwIdx++; } else { throwIdx += 2; } } if (frameIdx == 9) { return 0; } if (throwIdx >= _throwCount) { return 10; } if (_throws[throwIdx] == 10) { return 0; } if (throwIdx + 1 >= _throwCount) { return 10 - _throws[throwIdx]; } return 10 - _throws[throwIdx] - _throws[throwIdx + 1]; } bool BowlingScore::_isStrike(uint8_t frameIndex) { uint8_t throwIdx = 0; for (uint8_t i = 0; i < frameIndex; i++) { if (_throws[throwIdx] == 10) { throwIdx++; } else { throwIdx += 2; } } return throwIdx < _throwCount && _throws[throwIdx] == 10; } bool BowlingScore::_isSpare(uint8_t frameIndex) { uint8_t throwIdx = 0; for (uint8_t i = 0; i < frameIndex; i++) { if (_throws[throwIdx] == 10) { throwIdx++; } else { throwIdx += 2; } } if (throwIdx + 1 >= _throwCount) { return false; } return _throws[throwIdx] != 10 && (_throws[throwIdx] + _throws[throwIdx + 1] == 10); } bool BowlingScore::_isFrameComplete(uint8_t frameIndex) { uint8_t throwIdx = 0; for (uint8_t i = 0; i < frameIndex; i++) { if (_throws[throwIdx] == 10) { throwIdx++; } else { throwIdx += 2; } } if (throwIdx >= _throwCount) { return false; } if (_throws[throwIdx] == 10) { return true; } return throwIdx + 1 < _throwCount; } int BowlingScore::getFrameScore(uint8_t frameIndex) { if (frameIndex > 9) { return 0; } uint8_t throwIdx = 0; for (uint8_t i = 0; i < frameIndex; i++) { if (_throws[throwIdx] == 10) { throwIdx++; } else { throwIdx += 2; } } int score = 0; if (_isStrike(frameIndex)) { score = 10; if (throwIdx + 1 < _throwCount) { score += _throws[throwIdx + 1]; } if (throwIdx + 2 < _throwCount) { score += _throws[throwIdx + 2]; } } else if (_isSpare(frameIndex)) { score = 10; if (throwIdx + 2 < _throwCount) { score += _throws[throwIdx + 2]; } } else { if (throwIdx < _throwCount) { score += _throws[throwIdx]; } if (throwIdx + 1 < _throwCount) { score += _throws[throwIdx + 1]; } } return score; } int BowlingScore::getCurrentTotalScore() { int total = 0; for (uint8_t i = 0; i < 10; i++) { total += getFrameScore(i); } return total; } void BowlingScore::_debugPrint() { Serial.print("ThrowCount="); Serial.println(_throwCount); for (uint8_t i = 0; i < _throwCount; i++) { Serial.print(_throws[i]); Serial.print(" "); } Serial.println(); }

这段代码的核心价值在于,getCurrentFrame()getFrameScore()都通过throwIdx按轮次推进,避免了大量索引计算错误。新手最容易犯的错是直接拿全局投球计数去算轮次,结果遇到全中时全部错位。用“按轮次扫投球记录”的方式,代码更贴近规则本身。

第 10 轮的结算逻辑可以单独验证。例如一局 12 个全中的完美局,投球记录是 10 重复 12 次,总分应该是 300。用getCurrentTotalScore()从第 1 轮结算到第 10 轮,最终结果就是 300。

6.3 本地 Web 比分展示

下面实现一个简单的 Web Server,将当前比分以 JSON 格式返回。浏览器定时请求这个接口,就能做到实时刷新。

// 文件路径:src/WebScoreServer.h #ifndef WEB_SCORE_SERVER_H #define WEB_SCORE_SERVER_H #include <WiFi.h> #include <WebServer.h> #include "BowlingScore.h" class WebScoreServer { public: WebScoreServer(BowlingScore* score, const char* ssid, const char* password); void begin(); void handleClient(); private: BowlingScore* _score; WebServer _server; void _sendJsonScore(); }; #endif
// 文件路径:src/WebScoreServer.cpp #include "WebScoreServer.h" WebScoreServer::WebScoreServer(BowlingScore* score, const char* ssid, const char* password) : _score(score), _server(80) { WiFi.begin(ssid, password); while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); } Serial.println(); Serial.print("WiFi connected, IP: "); Serial.println(WiFi.localIP()); } void WebScoreServer::begin() { _server.on("/", HTTP_GET, [this]() { _sendJsonScore(); }); _server.on("/reset", HTTP_POST, [this]() { _score->reset(); _sendJsonScore(); }); _server.begin(); Serial.println("HTTP server started"); } void WebScoreServer::_sendJsonScore() { String json = "{"; json += "\"frame\":" + String(_score->getCurrentFrame()) + ","; json += "\"total\":" + String(_score->getCurrentTotalScore()) + ","; json += "\"gameOver\":"; json += _score->isGameOver() ? "true" : "false"; json += "}"; _server.send(200, "application/json", json); } void WebScoreServer::handleClient() { _server.handleClient(); }

如果不想引入 ArduinoJson 库,上面的字符串拼接方式已经够用。真实项目中,浏览器端可以用fetch()XMLHttpRequest定时拉取这个接口,然后渲染成表格。

需要注意的一点是,ESP32 运行 Web Server 时,主循环不能长时间阻塞。传感器的轮询应该基于非阻塞的定时机制,否则页面刷新会出现明显卡顿。

6.4 主程序整合

现在把上面的模块组合进main.ino。这个主程序的核心逻辑是一个精简的状态机。

// 文件路径:src/main.cpp(PlatformIO 模式下) #include <Arduino.h> #include <U8g2lib.h> #include "PinSensor.h" #include "BowlingScore.h" #include "WebScoreServer.h" // 8 个瓶位对应的 GPIO const uint8_t pinArray[8] = {32, 33, 25, 26, 27, 14, 12, 13}; const uint8_t ballDetectPin = 4; const uint8_t resetPin = 5; const uint8_t manualThrowPin = 15; PinSensor pinSensor(pinArray, 8); BowlingScore score; U8G2_SSD1306_128X64_NONAME_F_HW_I2C u8g2(U8G2_R0, U8G2_KN_2); enum SystemState { WAIT_BALL, BALL_ROLLING, SCAN_SETTLE, CALC_SCORE, SHOW_RESULT, GAME_OVER }; SystemState currentState = WAIT_BALL; uint8_t lastStandingPins = 10; unsigned long lastScanTime = 0; const unsigned long scanIntervalMs = 50; const unsigned long settleDelayMs = 800; void setup() { Serial.begin(115200); pinMode(ballDetectPin, INPUT_PULLUP); pinMode(resetPin, INPUT_PULLUP); pinMode(manualThrowPin, INPUT_PULLUP); pinSensor.begin(); score.reset(); u8g2.begin(); // 如果不需要 Web 功能,可以把这三行注释掉 // WebScoreServer webServer(&score, "YOUR_SSID", "YOUR_PASSWORD"); // webServer.begin(); // …… 注意 webServer 不能是局部对象,略作调整 } void updateDisplay() { u8g2.clearBuffer(); u8g2.setFont(u8g2_font_ncenB08_tr); char line1[32]; snprintf(line1, sizeof(line1), "Frame: %d", score.getCurrentFrame()); u8g2.drawStr(2, 16, line1); char line2[32]; snprintf(line2, sizeof(line2), "Total: %d", score.getCurrentTotalScore()); u8g2.drawStr(2, 36, line2); if (currentState == GAME_OVER) { u8g2.drawStr(2, 56, "GAME OVER"); } else { u8g2.drawStr(2, 56, "READY"); } u8g2.sendBuffer(); } void loop() { if (digitalRead(resetPin) == LOW) { score.reset(); currentState = WAIT_BALL; lastStandingPins = 10; delay(300); } switch (currentState) { case WAIT_BALL: { uint8_t currentPins = pinSensor.scanStandingPins(); lastStandingPins = currentPins; // 球通过传感器检测到球滚动,或手动按键触发 if (digitalRead(ballDetectPin) == LOW || digitalRead(manualThrowPin) == LOW) { currentState = BALL_ROLLING; delay(200); } break; } case BALL_ROLLING: { // 等待球道上的球瓶状态稳定 // 用一个最简单的轮询:持续扫描,直到连续几次读取结果一致 static uint8_t stableCount = 0; uint8_t currentPins = pinSensor.scanStandingPins(); if (currentPins == lastStandingPins) { stableCount++; if (stableCount >= 3) { currentState = SCAN_SETTLE; stableCount = 0; } } else { lastStandingPins = currentPins; stableCount = 0; } break; } case SCAN_SETTLE: { delay(settleDelayMs); currentState = CALC_SCORE; break; } case CALC_SCORE: { uint8_t standing = pinSensor.getStandingCount(); uint8_t knockedDown = 10 - standing; bool over = score.recordThrow(knockedDown); currentState = SHOW_RESULT; if (over) { currentState = GAME_OVER; } break; } case SHOW_RESULT: { updateDisplay(); delay(1500); // 自动重置球瓶后回到等待状态 currentState = WAIT_BALL; lastStandingPins = 10; break; } case GAME_OVER: { updateDisplay(); break; } } updateDisplay(); delay(scanIntervalMs); }

这段代码的实际作用是,把前面所有模块串起来。PinSensor负责读取瓶位状态,BowlingScore负责计分,OLED 负责显示,主循环通过状态机控制流程。

注意:上面的WebScoreServer示例只是演示用法,实际项目中建议把webServer定义为全局对象,并在setup()中传入 WiFi 凭据。如果你只想先跑通核心逻辑,可以把 Web 相关部分注释掉,等本地逻辑稳定后再联调网络。

7. 运行验证与调试方法

7.1 最小验证流程

在没有真实球道的情况下,推荐按以下顺序验证:

第一步,验证传感器读取。把 8 个按键接在对应 GPIO 上,按下某个按键,用串口打印scanStandingPins()的结果,确认每个 pin 位对应关系正确。

第二步,验证计分规则。写一个临时测试函数,模拟输入 12 次全中,串口打印总分。预期结果是 300。再模拟一轮“10, 7, 2”的记录,确认第 1 轮结算为 19 分,第 2 轮后再追加下一轮奖励。

第三步,验证状态机。手动触发manualThrowPin,观察状态是否从WAIT_BALL正确流转到CALC_SCORE,OLED 是否更新了轮次和总分。

第四步,验证 Web 接口。连接 WiFi 后,用浏览器访问http://<ESP32的IP>/,能看到 JSON 数据。如果只显示{“frame”:1,”total”:0},说明通信链路正常。

7.2 如何判断系统是否正常

判断一个嵌入式项目是否成功,不能只看“编译通过”。我的建议标准是:

  • 连续跑完一整局 10 轮,没有出现状态卡死。
  • 每一轮击倒数与实际按键/传感器触发情况一致。
  • 每一轮总分和手工计算一致,尤其是全中、补中场景。
  • Web 页面能在局中持续刷新,不出现连接超时。
  • 按重置按钮后,比分归零,状态回到WAIT_BALL

如果任何一个环节不满足,优先检查对应模块的串口日志。把_debugPrint()打开,观察投球记录数组是否正确,这比盯着总分数直接猜错误要快得多。

8. 常见问题与排查思路

问题现象可能原因排查方式解决方案
ESP32 无法烧录GPIO 0/2 被外部拉低,驱动了 Boot 模式断开传感器后再烧录;检查引脚接线重新插拔 USB,按住 BOOT 键再试
传感器读数抖动剧烈未做软件滤波,环境光干扰串口打印原始电平,观察波形添加电容滤波,在_readPinStable中增加采样次数
计分错位,全中后分数不对轮次索引算法有误用 12 个全中测试数据;打开_debugPrint()检查getCurrentFrame()getFrameScore()throwIdx推进逻辑
OLED 不显示I2C 地址错误或接线错误用 I2C 扫描例程查看设备地址根据实际模块修改u8g2.begin()参数
Web 页面无法打开WiFi 未连接或 IP 错误串口打印WiFi.localIP()确认手机/电脑与 ESP32 在同一局域网
球瓶被击倒后没有触发计分球通过传感器未触发,或传感器状态不稳定检查ballDetectPin触发条件,串口打印状态调整触发逻辑,或改用双传感器确认球已通过
菜单状态卡死在BALL_ROLLING球瓶状态一直变化,稳定计数无法达到 3 次串口打印扫描结果,检查环境光干扰增大stableCount阈值,或增加滤波电容

9. 工程化建议与最佳实践

9.1 从模拟器开始,再上真实硬件

这个项目最容易出的问题,不是单个模块不会写,而是整体联调时不知道该信谁。所以我的建议是,先用按键或串口指令模拟所有传感器输入,规则层完全验证通过后,再接入真实红外对管。这样做,你每次只引入一个新变量,出问题时可以准确定位。

9.2 代码分层要克制,但不要过度设计

保龄球系统分为感知层、控制层、规则层、展示层已经足够。不建议为了“架构优雅”引入消息队列、多线程任务调度等重型机制。ESP32 虽然有双核,但对这个项目来说,单核主循环 + 状态机已经可以稳定运行。过度设计只会增加理解成本和调试成本。

9.3 日志规范

建议在关键状态切换处打印日志,例如:

Serial.printf("[STATE] %d -> %d\n", currentState, nextState);

recordThrow()后打印:

Serial.printf("[SCORE] Frame=%d, ThrowInFrame=%d, Knocked=%d, Total=%d\n", score.getCurrentFrame(), score.getThrowInFrame(), knockedDown, score.getCurrentTotalScore());

日志是嵌入式系统最重要的调试手段,比断点调试更通用,尤其适合现场排查。

9.4 关于电源稳定性

如果使用 8 路红外对管,建议不要全部由 ESP32 开发板的 3.3V 引脚供电。传感器总电流可能超过 200mA,长期运行会影响系统稳定性。推荐使用外部 5V 电源,通过 AMS1117 等稳压芯片降压到 3.3V,给传感器单独供电,同时共地。否则会出现传感器读数漂移、WiFi 随机断连等诡异问题。

9.5 数据备份与 OTA 升级

如果你的项目已经做得比较完整,后续还想优化代码,可以考虑开启 ESP32 的 OTA 升级功能。这样不需要每次改动都插 USB 线,通过局域网就能烧录新固件。不过 OTA 有风险,烧录中途断电可能导致变砖,所以建议保留物理串口下载作为兜底方案。

9.6 从保龄球项目延伸出去

做完这个项目后,你可以把规则层替换成其他游戏规则,把传感器层替换成其他输入源。例如:

  • 换成 8 路电容触摸传感器,做一个“触摸式射击记分靶”。
  • 换成 RFID 卡片读取,做一个刷卡积分的活动设备。
  • 把 Web 展示升级成 WebSocket 双工通信,做成多人在线积分对战。

ESP32 的价值恰恰在这里:它的 GPIO、WiFi、外设接口足够丰富,做完一个完整项目后,你会对“端侧设备如何处理真实物理信号、如何上报远端、如何展示结果”有更直观的理解,而不会停留在调用 API 的层面。

10. 总结与后续学习方向

这篇文章围绕 ESP32 Bowling System 展开,核心不是保龄球本身,而是完整嵌入式项目的方法论。你可以用这套思路举一反三,把传感器输入、业务规则、状态管理、本地交互串成一个端到端系统。

建议的实践路径是:先搭建最小硬件环境,用按键模拟击倒;跑通BowlingScore计分逻辑;加入 OLED 显示和状态机;最后接入传感器和 Web 展示。每完成一个阶段,停下来验证并整理日志,再进入下一步。这个项目的试错成本很低,非常适合作为 ESP32 进阶练手项目。

如果你已经完成了基础版本,下一步可以思考几个方向:

  • 引入 FreeRTOS 任务,把 Web 服务和传感器轮询彻底分离。
  • 增加数据存储,在掉电后保留历史比赛成绩,使用 NVS 或 SPIFFS。
  • 优化 Web 前端,用 Vue 或 React 做一个更漂亮的实时比分界面。
  • 把传感器方案替换成更接近真实球道的结构,例如用激光对射模块检测是否有球瓶未被击倒。

保龄球系统看着简单,真正实现后你会发现,它的难点从来不在传感器,也不在屏幕,而在规则状态的精确流转。把这一关跨过去,你再去设计其他嵌入式交互设备,就会顺畅很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询