尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

ESP32保龄球计分系统实战:状态机建模与Web交互

ESP32保龄球计分系统实战:状态机建模与Web交互 如果你最近在逛开源硬件社区可能会看到不少人晒出“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 只有 2KBFlash 是 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_OVER10 轮结束展示总成绩。这个状态机的核心价值是让代码主循环非常干净每个状态只做该做的事。传感器抖动不会在投球过程中导致误判因为BALL_ROLLING状态下系统不会立即采数而是等待一个稳定窗口。3.3 保龄球计分规则回顾很多没有打过保龄球的开发者会把计分想得太简单。需要先明确规则否则代码一定会出错。标准保龄球共 10 轮Frame每轮默认可以投两次球目标是把 10 个球瓶全部击倒。普通命中Open两球击倒总数不满 10本轮得分就是击倒数量。补中Spare两球击倒总数正好为 10本轮基础分 10 分再加上下一球击倒数量作为奖励。全中Strike第一球就击倒 10 个球瓶本轮基础分 10 分再加上下两球击倒数量作为奖励。第 10 轮特殊规则如果第 10 轮第一次投球全中或两球补中则追加一次投球机会用来计算奖励分。这意味着计分不能逐轮立刻结算因为全中和补中的奖励依赖于后续投球。一个常见的实现方案是先记录每一投的击倒数然后等数据齐全后再从第 1 轮开始逐轮计算总分。这个方案逻辑清晰调试方便不会因为中间状态复杂而出错。下面用一个表格展示标准计分场景轮次投球记录本轮说明本轮累计总分110Strike待定等后两球奖励暂时无法确定27, 29 分 第1轮奖励 192838, 2Spare待定等下一球奖励暂时无法确定410Strike第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 需要用到的第三方库根据上面的设计需要安装以下库U8g2或Adafruit_SSD1306用于 OLED 显示。WebServer.hESP32 内置用于本地 Web 展示。WiFi.hESP32 内置用于网络连接。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(); // 读取当前所有瓶位状态返回每个位置是否存在球瓶 // bit1 表示有瓶bit0 表示已被击倒 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.cppPlatformIO 模式下 #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_SCOREOLED 是否更新了轮次和总分。第四步验证 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 做一个更漂亮的实时比分界面。把传感器方案替换成更接近真实球道的结构例如用激光对射模块检测是否有球瓶未被击倒。保龄球系统看着简单真正实现后你会发现它的难点从来不在传感器也不在屏幕而在规则状态的精确流转。把这一关跨过去你再去设计其他嵌入式交互设备就会顺畅很多。
返回列表