1. 项目概述与核心价值如果你玩过基于Arduino或类似微控制器的双人游戏比如两个PyGamer或PyBadge之间对战可能会发现一个痛点每次开发新游戏都得从头写一遍无线通信、状态管理、用户输入处理这些“脏活累活”。代码重复不说调试起来更是让人头疼尤其是当无线信号不稳定或者游戏逻辑和通信逻辑搅在一起时问题排查简直是一场噩梦。这个项目就是为解决这个问题而生的。它是一个专门为Arduino平台特别是Adafruit的Arcada系列开发板设计的双人游戏引擎框架。它的核心价值在于将游戏逻辑与底层通信、状态机彻底解耦。作为开发者你只需要关心三件事1. 玩家这一步怎么走Move2. 走完之后结果如何判定Results3. 游戏的整体流程和画面Game。至于数据怎么打包、怎么通过RF69HCW无线电模块可靠地发送给对方、怎么同步游戏状态、谁先手这些繁琐但关键的底层细节引擎已经帮你封装好了。简单来说它提供了一套基于面向对象编程的“骨架”。你就像是在骨架上添加血肉你的游戏规则和界面就能快速构建出一个稳定、可玩的双人无线游戏。我们用它实现了经典的井字棋Tic-Tac-Toe和战舰Battleship代码清晰且易于扩展。接下来我会带你深入这个引擎的设计哲学、每一块“骨头”是怎么长的、以及在实际开发中如何避开那些我踩过的坑。2. 引擎核心架构与设计哲学2.1 为什么是面向对象与状态机在资源受限的嵌入式环境如Arduino谈面向对象OOP有些人可能会觉得“杀鸡用牛刀”。但在这个项目里OOP是提高代码可维护性和复用性的关键。游戏引擎的本质是处理一系列状态寻找对手、我的回合、对手回合、游戏结束和在这些状态间流转的事件发送移动、接收结果。用传统的全局变量和巨型switch-case来管理代码很快就会变得难以阅读和调试。我们的设计采用了清晰的类层次结构和状态机模式basePacket-baseMove/baseResults 所有需要无线传输的数据都继承自一个基础包类确保了数据传输接口的统一。baseGame 这是引擎的大脑内部维护一个gameState状态变量并在loopContents()中根据当前状态调用不同的处理函数。baseRadio 定义了无线通信的抽象接口让引擎不依赖于具体的无线电硬件。这种架构的好处是高内聚、低耦合。你的游戏类如TTT_Game继承自baseGame只需重写几个关键虚函数如决定谁先手的coinFlip()、游戏结束的processGameOver()。无线通信部分我们提供了基于RF69HCW和RadioHead库的现成实现RF69Radio你也可以替换成其他通信方式如红外、蓝牙只要实现baseRadio的接口即可。2.2 数据传输为什么选择二进制而非JSON原文提到了一个关键选择使用二进制格式打包数据而非更易读的JSON。这是嵌入式开发中一个经典的权衡。根本原因在于严格的资源限制。RF69HCW无线电模块的单次数据包最大容量通常只有60字节。JSON虽然人类可读但冗余信息太多。一个简单的{move: 5, type: MOVE}这样的字符串加上引号、括号、键名轻松超过20字节。如果你的游戏状态更复杂比如战舰游戏需要传送多个坐标和状态60字节的限额很快就会捉襟见肘。二进制传输的优势极致紧凑 一个uint8_t类型的移动位置如井字棋的格子索引只占1字节。加上必要的包头信息包类型、子类型一个移动包可以控制在10字节以内。处理高效 无需在发送端序列化sprintf或ArduinoJson库处理也无需在接收端反序列化解析字符串。数据在内存中是什么样发送出去就是什么样接收方直接按结构体解读极大节省了CPU时间和内存。实现关键与坑点 引擎采用了一个巧妙但也需要小心的方法直接传输对象的内存映像。send函数发送的起始地址是this (2 * sizeof(void*))跳过了对象前部的虚函数表指针和Radio对象指针。这就要求警告在派生Move和Results类时绝对不要使用指针指向动态数据。因为传输的是指针值本身而不是指针指向的数据。对端设备上的内存布局完全不同这个指针值将是无效的甚至会导致程序崩溃。所有需要传输的数据都必须是直接内嵌在对象中的基本数据类型int,uint8_t,char数组等。3. 核心类深度解析与实现要点3.1 basePacket所有数据包的基石basePacket类是所有可传输数据的基类。它非常精简主要包含类型标识和无线电指针。enum packetType_t { NO_PACKET_TYPE, OFFERING_GAME_PACKET, ACCEPTING_GAME_PACKET, MOVE_PACKET, RESULTS_PACKET, COIN_FLIP_PACKET }; enum packetSubType_t { NO_SUBTYPE, NORMAL_MOVE, PASS_MOVE, QUIT_MOVE, NORMAL_RESULTS, HIT_RESULTS, MISS_RESULTS, WIN_RESULTS, LOSE_RESULTS, TIE_RESULTS, FLIP_TRUE, FLIP_FALSE }; class basePacket { public: baseRadio* Radio; // 通信依赖 packetType_t type; packetSubType_t subType; // ... 构造函数与方法 virtual size_t my_size() { return sizeof(*this); } virtual bool send(void); };my_size()的玄机 这是一个虚函数默认返回sizeof(*this)。但在多态情况下如果通过basePacket*指针操作一个derivedMove对象调用basePacket的my_size()只会返回基类的大小。因此在每一个派生类中都必须重写override这个函数并返回sizeof(*this)以确保引擎能获取到派生类的真实大小从而传输完整数据。这是实现“直接传输对象内存”方案的技术关键。3.2 baseMove 与 baseResults游戏逻辑的载体这两个类继承自basePacket是游戏逻辑与通信引擎交互的桥梁。baseMove(移动类)核心数据moveNum回合序号从基类继承的type固定为MOVE_PACKET和subType如NORMAL_MOVE。核心方法decideMyMove()。这是一个纯虚函数你必须在你自己的游戏类如TTT_Move中实现它。在这里你需要读取玩家输入摇杆、按钮、触摸屏并将合法的移动选择赋值给派生类中自定义的成员变量例如uint8_t square;。工作流程 当引擎进入MY_TURN状态时会调用你的decideMyMove()。你只需要在这个函数里完成“收集玩家输入 - 验证 - 存入成员变量”的操作然后返回。引擎会自动调用send()方法将整个Move对象发出去。baseResults(结果类)核心数据resultsNum对应的移动序号继承的typeRESULTS_PACKET和subType如HIT_RESULTS,WIN_RESULTS。核心方法generateResults(baseMove* Move)你来实现。当收到对手的移动Move对象后引擎调用此函数。你需要根据游戏规则判断这个移动的结果命中/未命中、输/赢/平局并设置好Results对象的subType和任何其他自定义数据如击毁了哪艘船然后返回true如果游戏结束或false游戏继续。processResults()你来实现。当你发送移动后会收到对手回传的Results包。这个函数用于处理这个结果例如更新自己的游戏界面显示“命中”或者判断自己是否胜利。一个重要的设计理念 胜负判定逻辑只存在于generateResults()中。也就是说由“防守方”来判断“进攻方”的移动是否有效并决定游戏状态。这保证了逻辑的唯一性避免了双端计算可能带来的状态不一致。3.3 baseGame游戏状态的总指挥baseGame类是引擎的调度中心。你的主程序简单到令人发指void setup() { Game.setup(); } void loop() { Game.loopContents(); }所有复杂性都被封装在了baseGame及其派生类中。状态机gameState_t是核心OFFERING_GAME 设备作为“主机”广播寻找对手。SEEKING_GAME 设备作为“客机”扫描并加入游戏。MY_TURN 我的回合调用Move.decideMyMove()并发送。OPPONENTS_TURN 对手回合等待接收Move包接收后调用Results.generateResults()并回发。GAME_OVER 游戏结束显示结果等待重启。必须实现的纯虚函数bool coinFlip() 决定谁先手。可以简单返回random(2)也可以实现更复杂的机制如剪刀石头布。返回true表示 offering player主机先手。void processGameOver() 游戏结束时调用用于显示胜利/失败画面、播放音效、等待重启输入等。void fatalError(const char* s) 发生不可恢复错误如通信彻底失败时调用应至少通过串口打印错误信息s。高级技巧钩子函数HookbaseGame::loopContents()是一个简单的状态切换器。但有时你需要在引擎处理某个状态之前或之后插入自己的代码。例如在战舰游戏中你希望在对手回合开始时播放一段“等待对手攻击”的音效。 你可以像示例中那样在你的派生Game类中重写loopContents()void MyGame::loopContents() { gameState_t savedState gameState; // 保存当前状态 // 前置处理 switch(gameState) { case OPPONENTS_TURN: playSound(waiting.wav); break; } // 必须调用基类方法让引擎执行核心逻辑 baseGame::loopContents(); // 后置处理使用保存的状态因为基类调用可能已改变gameState switch(savedState) { case MY_TURN: updateDisplay(); break; } }4. 无线通信层RF69HCW与可靠传输4.1 RadioHead库与RHReliableDatagram引擎的通信基石是优秀的RadioHead库特别是其中的RHReliableDatagram类。它为我们提供了“开箱即用”的可靠数据传输自动应答ACK 每发送一个数据包接收方都会自动回复一个ACK确认。发送方直到收到ACK或超时重试多次后才会认为发送失败。数据加密 可以轻松启用AES加密防止数据被窃听虽然对于游戏可能不是必须的。地址过滤 每个设备有一个地址Player 1 和 Player 2确保数据包不会被无关设备接收。我们的RF69Radio类就是对RHReliableDatagram的一层薄包装实现了baseRadio的抽象接口。send()和recvTimeout()等方法内部直接调用了RadioHead的对应函数。关于命名的一个小坑 代码中我们一直叫RF69Radio但Adafruit的模块全名是RFM69HCW。这是因为RadioHead库内部将其简称为RF69。为了保持代码内部统一我们沿用了RF69这个称呼这点在阅读代码和查找文档时需要注意。4.2 处理阻塞操作与并发在Arduino的单线程环境中长延时delay()是无线通信的大敌。因为delay()会阻塞整个程序导致无线电模块无法及时发送ACK或监听信道从而造成通信超时和失败。实战中的解决方案避免使用delay() 在游戏循环中绝对不要用长延时。如果需要定时使用millis()进行非阻塞计时。在必须的延时中“让步” 有时播放音效或复杂动画不得不占用时间。在战舰游戏的示例中我们创建了一个myDelay()函数void myDelay(unsigned long ms) { unsigned long start millis(); while (millis() - start ms) { Radio-poll(); // 假设Radio对象有这样一个方法用于处理底层通信后台任务 // 或者更通用的 yield(); // 在Arduino框架中yield()允许后台任务如串口、无线运行 // 执行其他非阻塞的小任务如更新动画帧 } }yield()函数对于基于ESP32或使用某些库的Arduino兼容板尤其重要它能让看门狗和网络栈得以运行。对于RadioHead库定期调用manager.poll()或在我们的封装里调用某个处理函数可以确保ACK能被及时发送和接收。5. 实战从零构建一个“猜数字”双人游戏为了彻底理解引擎我们抛开井字棋和战舰实现一个更简单的“猜数字”游戏Player 1想一个1-100的数字Player 2来猜Player 1回答“大了”、“小了”或“猜中了”。5.1 定义Move和Results类首先我们定义移动包。玩家2的移动就是猜的数字。// GuessMove.h #include TwoPlayerGame.h class GuessMove : public baseMove { public: uint8_t guessedNumber; // 猜的数字 (1-100) size_t my_size() override { return sizeof(*this); }; void decideMyMove(void) override; };decideMyMove的实现假设使用Arcada库的摇杆和屏幕void GuessMove::decideMyMove(void) { uint8_t guess 50; // 初始猜测值 displayNumber(guess); // 自定义函数在屏幕上显示数字 while(true) { if(Device.justPressedButtons() ARCADA_BUTTONMASK_A) { // 按下A键确认猜测 if(guess 1 guess 100) { guessedNumber guess; return; // 退出函数引擎会自动发送 } } // 使用摇杆上下调整数字 if(Device.readJoystickY() 200) { // 摇杆向上 if(guess 100) guess; displayNumber(guess); myDelay(150); // 使用非阻塞延时 } if(Device.readJoystickY() -200) { // 摇杆向下 if(guess 1) guess--; displayNumber(guess); myDelay(150); } yield(); // 重要让出CPU时间 } }接着定义结果包。Player 1需要告诉Player 2猜测的结果。// GuessResults.h enum guessResult_t {GUESS_TOO_HIGH, GUESS_TOO_LOW, GUESS_CORRECT, GUESS_INVALID}; class GuessResults : public baseResults { public: guessResult_t result; size_t my_size() override { return sizeof(*this); }; bool processResults(void) override; bool generateResults(baseMove* Move) override; };generateResults是游戏逻辑的核心bool GuessResults::generateResults(baseMove* Move) { GuessMove* gm (GuessMove*)Move; // 安全转换因为我们知道传入的是GuessMove uint8_t secretNumber getSecretNumber(); // 从某个地方获取Player1设定的数字 resultsNum gm-moveNum; // 重要关联结果与移动的序号 if(gm-guessedNumber secretNumber) { subType WIN_RESULTS; // 对于猜数字游戏猜中即赢 result GUESS_CORRECT; return true; // 游戏结束 } else if(gm-guessedNumber secretNumber) { subType NORMAL_RESULTS; result GUESS_TOO_HIGH; } else { subType NORMAL_RESULTS; result GUESS_TOO_LOW; } return false; // 游戏继续 }processResults用于处理收到的结果bool GuessResults::processResults(void) { switch(result) { case GUESS_TOO_HIGH: displayMessage(Too High!); break; case GUESS_TOO_LOW: displayMessage(Too Low!); break; case GUESS_CORRECT: displayMessage(You Win!); return true; // 游戏结束 default: break; } return false; // 游戏继续 }5.2 定义Game类// GuessGame.h class GuessGame : public baseGame { public: GuessGame(baseMove* move, baseResults* res, baseRadio* radio, bool isPlayer1) : baseGame(move, res, radio, isPlayer1) {}; bool coinFlip(void) override { // 简单随机决定谁先手。猜数字游戏通常由“出题人”先手等待对方猜。 // 这里我们让Offering Player主机作为出题人。 return true; // Offering Player (Player 1) 先进入“等待猜测”状态 } void processGameOver(void) override { if(myPlayerNum 1) { displayMessage(Player 2 Guessed it!); } else { displayMessage(You Guessed it!); } waitForRestart(); // 等待按键重启游戏 } void fatalError(const char* s) override { displayMessage(Fatal Error:); displayMessage(s); // 假设有显示函数 while(1) { yield(); } // 死循环等待复位 } private: uint8_t secretNumber; void initialize(void) override { baseGame::initialize(); // 必须调用基类初始化 if(myPlayerNum 1) { // Player 1: 随机生成一个秘密数字 randomSeed(analogRead(A0)); // 使用未连接的模拟引脚获取噪声 secretNumber random(1, 101); setSecretNumber(secretNumber); // 存储起来供generateResults使用 } } };5.3 主程序主程序与示例中完全一致体现了框架的简洁性#define IS_PLAYER_1 true // 编译Player1固件时设为truePlayer2时设为false #include TwoPlayerGame.h #include TwoPlayerGame_RF69HCW.h #include GuessMove.h #include GuessResults.h #include GuessGame.h RF69Radio Radio; GuessMove Move; GuessResults Results; GuessGame Game(Move, Results, Radio, IS_PLAYER_1); void setup() { Game.setup(); } void loop() { Game.loopContents(); }你需要编译两个固件一个IS_PLAYER_1为true另一个为false分别烧录到两个设备上。6. 调试技巧与常见问题排查开发这类实时无线交互应用调试是一大挑战。引擎内置了调试支持在TwoPlayerGame.h中可以通过定义TPG_DEBUG为1来开启。6.1 串口调试输出开启调试后basePacket及其派生类的print()方法会将包的内容以可读格式输出到串口监视器。这对于验证数据是否正确打包和解包至关重要。例如你可以看到发送的moveNum、square等值是否正确。操作 在Arduino IDE中打开串口监视器确保波特率设置正确通常为115200。观察两个设备间的数据流。6.2 常见问题与解决方案设备无法连接一直停留在OFFERING/SEEKING状态检查硬件 确认RF69HCW模块天线已焊接且两个设备供电充足。无线电对电源噪声敏感建议使用质量好的电池或稳压电源。检查地址 确认两个设备的IS_PLAYER_1设置不同一个true一个false。地址冲突会导致无法通信。检查频率和加密密钥 确保RF69Radio初始化代码中的无线电频率如433.0, 915.0在两个设备上一致且加密密钥如果有也完全一致。距离与障碍物 初始测试时将两个设备放在一米内无遮挡。RF69HCW虽然号称能传几百米但那是在理想条件下。游戏运行中通信超时Fatal Error首要嫌疑delay() 在你的decideMyMove()、processResults()或任何游戏循环函数中是否使用了delay()将其全部替换为基于millis()的非阻塞延时或myDelay()。检查yield() 在长循环中如等待玩家输入务必定期调用yield()让无线电栈有机会处理后台任务发送ACK、监听信道。降低数据传输量 虽然引擎已很高效但如果你的Move或Results类非常大接近60字节尝试简化数据结构。增加重试次数 在RF69Radio的实现中实际是RadioHead库可以尝试修改RHReliableDatagram的默认重试次数和超时时间但这不是根本解决办法。游戏逻辑不同步一方显示胜利另一方没反应调试generateResults逻辑 这是最常见的bug来源。用串口打印出秘密数字、收到的猜测数字、以及判断逻辑的每一步结果确保规则在所有边界情况下都正确。检查subType赋值 确保在游戏结束时如猜中数字generateResults返回true并且subType被正确设置为WIN_RESULTS或LOSE_RESULTS。引擎依赖这个来触发GAME_OVER状态。验证processResults处理 确保它正确读取了subType和自定义的result字段并更新了本地游戏状态和界面。编译错误“undefined reference tovtable for ...”这是C虚函数表链接错误。根本原因是你在头文件中声明了虚函数如decideMyMove()但在对应的.cpp文件中没有提供它的实现体。确保每一个声明的虚函数都有定义即使是空定义。运行不稳定偶尔死机堆栈溢出 Arduino内存有限。检查是否在函数内定义了大型数组如屏幕缓冲区。考虑使用全局变量或PROGMEM。无线电中断冲突 RF69HCW使用中断来接收数据。确保你没有在其他地方禁用全局中断cli()太长时间。电源问题 在发送无线电信号时模块会瞬间消耗较大电流可达100mA以上。如果电源内阻大或容量不足会导致电压骤降引起单片机复位。使用电容如100uF靠近模块电源引脚进行稳压。这个引擎将无线双人游戏开发中最复杂、最容易出错的部分标准化了。一旦你理解了Move、Results、Game这三个类的职责和交互方式开发新游戏就变成了纯粹的逻辑和创意工作。你可以专注于设计有趣的玩法、绘制精美的像素画、添加动人的音效而不用担心数据会不会丢包、状态会不会乱掉。这种“分离关注点”的设计正是高效嵌入式开发的精髓所在。
Arduino双人无线游戏引擎:面向对象与状态机设计实践
1. 项目概述与核心价值如果你玩过基于Arduino或类似微控制器的双人游戏比如两个PyGamer或PyBadge之间对战可能会发现一个痛点每次开发新游戏都得从头写一遍无线通信、状态管理、用户输入处理这些“脏活累活”。代码重复不说调试起来更是让人头疼尤其是当无线信号不稳定或者游戏逻辑和通信逻辑搅在一起时问题排查简直是一场噩梦。这个项目就是为解决这个问题而生的。它是一个专门为Arduino平台特别是Adafruit的Arcada系列开发板设计的双人游戏引擎框架。它的核心价值在于将游戏逻辑与底层通信、状态机彻底解耦。作为开发者你只需要关心三件事1. 玩家这一步怎么走Move2. 走完之后结果如何判定Results3. 游戏的整体流程和画面Game。至于数据怎么打包、怎么通过RF69HCW无线电模块可靠地发送给对方、怎么同步游戏状态、谁先手这些繁琐但关键的底层细节引擎已经帮你封装好了。简单来说它提供了一套基于面向对象编程的“骨架”。你就像是在骨架上添加血肉你的游戏规则和界面就能快速构建出一个稳定、可玩的双人无线游戏。我们用它实现了经典的井字棋Tic-Tac-Toe和战舰Battleship代码清晰且易于扩展。接下来我会带你深入这个引擎的设计哲学、每一块“骨头”是怎么长的、以及在实际开发中如何避开那些我踩过的坑。2. 引擎核心架构与设计哲学2.1 为什么是面向对象与状态机在资源受限的嵌入式环境如Arduino谈面向对象OOP有些人可能会觉得“杀鸡用牛刀”。但在这个项目里OOP是提高代码可维护性和复用性的关键。游戏引擎的本质是处理一系列状态寻找对手、我的回合、对手回合、游戏结束和在这些状态间流转的事件发送移动、接收结果。用传统的全局变量和巨型switch-case来管理代码很快就会变得难以阅读和调试。我们的设计采用了清晰的类层次结构和状态机模式basePacket-baseMove/baseResults 所有需要无线传输的数据都继承自一个基础包类确保了数据传输接口的统一。baseGame 这是引擎的大脑内部维护一个gameState状态变量并在loopContents()中根据当前状态调用不同的处理函数。baseRadio 定义了无线通信的抽象接口让引擎不依赖于具体的无线电硬件。这种架构的好处是高内聚、低耦合。你的游戏类如TTT_Game继承自baseGame只需重写几个关键虚函数如决定谁先手的coinFlip()、游戏结束的processGameOver()。无线通信部分我们提供了基于RF69HCW和RadioHead库的现成实现RF69Radio你也可以替换成其他通信方式如红外、蓝牙只要实现baseRadio的接口即可。2.2 数据传输为什么选择二进制而非JSON原文提到了一个关键选择使用二进制格式打包数据而非更易读的JSON。这是嵌入式开发中一个经典的权衡。根本原因在于严格的资源限制。RF69HCW无线电模块的单次数据包最大容量通常只有60字节。JSON虽然人类可读但冗余信息太多。一个简单的{move: 5, type: MOVE}这样的字符串加上引号、括号、键名轻松超过20字节。如果你的游戏状态更复杂比如战舰游戏需要传送多个坐标和状态60字节的限额很快就会捉襟见肘。二进制传输的优势极致紧凑 一个uint8_t类型的移动位置如井字棋的格子索引只占1字节。加上必要的包头信息包类型、子类型一个移动包可以控制在10字节以内。处理高效 无需在发送端序列化sprintf或ArduinoJson库处理也无需在接收端反序列化解析字符串。数据在内存中是什么样发送出去就是什么样接收方直接按结构体解读极大节省了CPU时间和内存。实现关键与坑点 引擎采用了一个巧妙但也需要小心的方法直接传输对象的内存映像。send函数发送的起始地址是this (2 * sizeof(void*))跳过了对象前部的虚函数表指针和Radio对象指针。这就要求警告在派生Move和Results类时绝对不要使用指针指向动态数据。因为传输的是指针值本身而不是指针指向的数据。对端设备上的内存布局完全不同这个指针值将是无效的甚至会导致程序崩溃。所有需要传输的数据都必须是直接内嵌在对象中的基本数据类型int,uint8_t,char数组等。3. 核心类深度解析与实现要点3.1 basePacket所有数据包的基石basePacket类是所有可传输数据的基类。它非常精简主要包含类型标识和无线电指针。enum packetType_t { NO_PACKET_TYPE, OFFERING_GAME_PACKET, ACCEPTING_GAME_PACKET, MOVE_PACKET, RESULTS_PACKET, COIN_FLIP_PACKET }; enum packetSubType_t { NO_SUBTYPE, NORMAL_MOVE, PASS_MOVE, QUIT_MOVE, NORMAL_RESULTS, HIT_RESULTS, MISS_RESULTS, WIN_RESULTS, LOSE_RESULTS, TIE_RESULTS, FLIP_TRUE, FLIP_FALSE }; class basePacket { public: baseRadio* Radio; // 通信依赖 packetType_t type; packetSubType_t subType; // ... 构造函数与方法 virtual size_t my_size() { return sizeof(*this); } virtual bool send(void); };my_size()的玄机 这是一个虚函数默认返回sizeof(*this)。但在多态情况下如果通过basePacket*指针操作一个derivedMove对象调用basePacket的my_size()只会返回基类的大小。因此在每一个派生类中都必须重写override这个函数并返回sizeof(*this)以确保引擎能获取到派生类的真实大小从而传输完整数据。这是实现“直接传输对象内存”方案的技术关键。3.2 baseMove 与 baseResults游戏逻辑的载体这两个类继承自basePacket是游戏逻辑与通信引擎交互的桥梁。baseMove(移动类)核心数据moveNum回合序号从基类继承的type固定为MOVE_PACKET和subType如NORMAL_MOVE。核心方法decideMyMove()。这是一个纯虚函数你必须在你自己的游戏类如TTT_Move中实现它。在这里你需要读取玩家输入摇杆、按钮、触摸屏并将合法的移动选择赋值给派生类中自定义的成员变量例如uint8_t square;。工作流程 当引擎进入MY_TURN状态时会调用你的decideMyMove()。你只需要在这个函数里完成“收集玩家输入 - 验证 - 存入成员变量”的操作然后返回。引擎会自动调用send()方法将整个Move对象发出去。baseResults(结果类)核心数据resultsNum对应的移动序号继承的typeRESULTS_PACKET和subType如HIT_RESULTS,WIN_RESULTS。核心方法generateResults(baseMove* Move)你来实现。当收到对手的移动Move对象后引擎调用此函数。你需要根据游戏规则判断这个移动的结果命中/未命中、输/赢/平局并设置好Results对象的subType和任何其他自定义数据如击毁了哪艘船然后返回true如果游戏结束或false游戏继续。processResults()你来实现。当你发送移动后会收到对手回传的Results包。这个函数用于处理这个结果例如更新自己的游戏界面显示“命中”或者判断自己是否胜利。一个重要的设计理念 胜负判定逻辑只存在于generateResults()中。也就是说由“防守方”来判断“进攻方”的移动是否有效并决定游戏状态。这保证了逻辑的唯一性避免了双端计算可能带来的状态不一致。3.3 baseGame游戏状态的总指挥baseGame类是引擎的调度中心。你的主程序简单到令人发指void setup() { Game.setup(); } void loop() { Game.loopContents(); }所有复杂性都被封装在了baseGame及其派生类中。状态机gameState_t是核心OFFERING_GAME 设备作为“主机”广播寻找对手。SEEKING_GAME 设备作为“客机”扫描并加入游戏。MY_TURN 我的回合调用Move.decideMyMove()并发送。OPPONENTS_TURN 对手回合等待接收Move包接收后调用Results.generateResults()并回发。GAME_OVER 游戏结束显示结果等待重启。必须实现的纯虚函数bool coinFlip() 决定谁先手。可以简单返回random(2)也可以实现更复杂的机制如剪刀石头布。返回true表示 offering player主机先手。void processGameOver() 游戏结束时调用用于显示胜利/失败画面、播放音效、等待重启输入等。void fatalError(const char* s) 发生不可恢复错误如通信彻底失败时调用应至少通过串口打印错误信息s。高级技巧钩子函数HookbaseGame::loopContents()是一个简单的状态切换器。但有时你需要在引擎处理某个状态之前或之后插入自己的代码。例如在战舰游戏中你希望在对手回合开始时播放一段“等待对手攻击”的音效。 你可以像示例中那样在你的派生Game类中重写loopContents()void MyGame::loopContents() { gameState_t savedState gameState; // 保存当前状态 // 前置处理 switch(gameState) { case OPPONENTS_TURN: playSound(waiting.wav); break; } // 必须调用基类方法让引擎执行核心逻辑 baseGame::loopContents(); // 后置处理使用保存的状态因为基类调用可能已改变gameState switch(savedState) { case MY_TURN: updateDisplay(); break; } }4. 无线通信层RF69HCW与可靠传输4.1 RadioHead库与RHReliableDatagram引擎的通信基石是优秀的RadioHead库特别是其中的RHReliableDatagram类。它为我们提供了“开箱即用”的可靠数据传输自动应答ACK 每发送一个数据包接收方都会自动回复一个ACK确认。发送方直到收到ACK或超时重试多次后才会认为发送失败。数据加密 可以轻松启用AES加密防止数据被窃听虽然对于游戏可能不是必须的。地址过滤 每个设备有一个地址Player 1 和 Player 2确保数据包不会被无关设备接收。我们的RF69Radio类就是对RHReliableDatagram的一层薄包装实现了baseRadio的抽象接口。send()和recvTimeout()等方法内部直接调用了RadioHead的对应函数。关于命名的一个小坑 代码中我们一直叫RF69Radio但Adafruit的模块全名是RFM69HCW。这是因为RadioHead库内部将其简称为RF69。为了保持代码内部统一我们沿用了RF69这个称呼这点在阅读代码和查找文档时需要注意。4.2 处理阻塞操作与并发在Arduino的单线程环境中长延时delay()是无线通信的大敌。因为delay()会阻塞整个程序导致无线电模块无法及时发送ACK或监听信道从而造成通信超时和失败。实战中的解决方案避免使用delay() 在游戏循环中绝对不要用长延时。如果需要定时使用millis()进行非阻塞计时。在必须的延时中“让步” 有时播放音效或复杂动画不得不占用时间。在战舰游戏的示例中我们创建了一个myDelay()函数void myDelay(unsigned long ms) { unsigned long start millis(); while (millis() - start ms) { Radio-poll(); // 假设Radio对象有这样一个方法用于处理底层通信后台任务 // 或者更通用的 yield(); // 在Arduino框架中yield()允许后台任务如串口、无线运行 // 执行其他非阻塞的小任务如更新动画帧 } }yield()函数对于基于ESP32或使用某些库的Arduino兼容板尤其重要它能让看门狗和网络栈得以运行。对于RadioHead库定期调用manager.poll()或在我们的封装里调用某个处理函数可以确保ACK能被及时发送和接收。5. 实战从零构建一个“猜数字”双人游戏为了彻底理解引擎我们抛开井字棋和战舰实现一个更简单的“猜数字”游戏Player 1想一个1-100的数字Player 2来猜Player 1回答“大了”、“小了”或“猜中了”。5.1 定义Move和Results类首先我们定义移动包。玩家2的移动就是猜的数字。// GuessMove.h #include TwoPlayerGame.h class GuessMove : public baseMove { public: uint8_t guessedNumber; // 猜的数字 (1-100) size_t my_size() override { return sizeof(*this); }; void decideMyMove(void) override; };decideMyMove的实现假设使用Arcada库的摇杆和屏幕void GuessMove::decideMyMove(void) { uint8_t guess 50; // 初始猜测值 displayNumber(guess); // 自定义函数在屏幕上显示数字 while(true) { if(Device.justPressedButtons() ARCADA_BUTTONMASK_A) { // 按下A键确认猜测 if(guess 1 guess 100) { guessedNumber guess; return; // 退出函数引擎会自动发送 } } // 使用摇杆上下调整数字 if(Device.readJoystickY() 200) { // 摇杆向上 if(guess 100) guess; displayNumber(guess); myDelay(150); // 使用非阻塞延时 } if(Device.readJoystickY() -200) { // 摇杆向下 if(guess 1) guess--; displayNumber(guess); myDelay(150); } yield(); // 重要让出CPU时间 } }接着定义结果包。Player 1需要告诉Player 2猜测的结果。// GuessResults.h enum guessResult_t {GUESS_TOO_HIGH, GUESS_TOO_LOW, GUESS_CORRECT, GUESS_INVALID}; class GuessResults : public baseResults { public: guessResult_t result; size_t my_size() override { return sizeof(*this); }; bool processResults(void) override; bool generateResults(baseMove* Move) override; };generateResults是游戏逻辑的核心bool GuessResults::generateResults(baseMove* Move) { GuessMove* gm (GuessMove*)Move; // 安全转换因为我们知道传入的是GuessMove uint8_t secretNumber getSecretNumber(); // 从某个地方获取Player1设定的数字 resultsNum gm-moveNum; // 重要关联结果与移动的序号 if(gm-guessedNumber secretNumber) { subType WIN_RESULTS; // 对于猜数字游戏猜中即赢 result GUESS_CORRECT; return true; // 游戏结束 } else if(gm-guessedNumber secretNumber) { subType NORMAL_RESULTS; result GUESS_TOO_HIGH; } else { subType NORMAL_RESULTS; result GUESS_TOO_LOW; } return false; // 游戏继续 }processResults用于处理收到的结果bool GuessResults::processResults(void) { switch(result) { case GUESS_TOO_HIGH: displayMessage(Too High!); break; case GUESS_TOO_LOW: displayMessage(Too Low!); break; case GUESS_CORRECT: displayMessage(You Win!); return true; // 游戏结束 default: break; } return false; // 游戏继续 }5.2 定义Game类// GuessGame.h class GuessGame : public baseGame { public: GuessGame(baseMove* move, baseResults* res, baseRadio* radio, bool isPlayer1) : baseGame(move, res, radio, isPlayer1) {}; bool coinFlip(void) override { // 简单随机决定谁先手。猜数字游戏通常由“出题人”先手等待对方猜。 // 这里我们让Offering Player主机作为出题人。 return true; // Offering Player (Player 1) 先进入“等待猜测”状态 } void processGameOver(void) override { if(myPlayerNum 1) { displayMessage(Player 2 Guessed it!); } else { displayMessage(You Guessed it!); } waitForRestart(); // 等待按键重启游戏 } void fatalError(const char* s) override { displayMessage(Fatal Error:); displayMessage(s); // 假设有显示函数 while(1) { yield(); } // 死循环等待复位 } private: uint8_t secretNumber; void initialize(void) override { baseGame::initialize(); // 必须调用基类初始化 if(myPlayerNum 1) { // Player 1: 随机生成一个秘密数字 randomSeed(analogRead(A0)); // 使用未连接的模拟引脚获取噪声 secretNumber random(1, 101); setSecretNumber(secretNumber); // 存储起来供generateResults使用 } } };5.3 主程序主程序与示例中完全一致体现了框架的简洁性#define IS_PLAYER_1 true // 编译Player1固件时设为truePlayer2时设为false #include TwoPlayerGame.h #include TwoPlayerGame_RF69HCW.h #include GuessMove.h #include GuessResults.h #include GuessGame.h RF69Radio Radio; GuessMove Move; GuessResults Results; GuessGame Game(Move, Results, Radio, IS_PLAYER_1); void setup() { Game.setup(); } void loop() { Game.loopContents(); }你需要编译两个固件一个IS_PLAYER_1为true另一个为false分别烧录到两个设备上。6. 调试技巧与常见问题排查开发这类实时无线交互应用调试是一大挑战。引擎内置了调试支持在TwoPlayerGame.h中可以通过定义TPG_DEBUG为1来开启。6.1 串口调试输出开启调试后basePacket及其派生类的print()方法会将包的内容以可读格式输出到串口监视器。这对于验证数据是否正确打包和解包至关重要。例如你可以看到发送的moveNum、square等值是否正确。操作 在Arduino IDE中打开串口监视器确保波特率设置正确通常为115200。观察两个设备间的数据流。6.2 常见问题与解决方案设备无法连接一直停留在OFFERING/SEEKING状态检查硬件 确认RF69HCW模块天线已焊接且两个设备供电充足。无线电对电源噪声敏感建议使用质量好的电池或稳压电源。检查地址 确认两个设备的IS_PLAYER_1设置不同一个true一个false。地址冲突会导致无法通信。检查频率和加密密钥 确保RF69Radio初始化代码中的无线电频率如433.0, 915.0在两个设备上一致且加密密钥如果有也完全一致。距离与障碍物 初始测试时将两个设备放在一米内无遮挡。RF69HCW虽然号称能传几百米但那是在理想条件下。游戏运行中通信超时Fatal Error首要嫌疑delay() 在你的decideMyMove()、processResults()或任何游戏循环函数中是否使用了delay()将其全部替换为基于millis()的非阻塞延时或myDelay()。检查yield() 在长循环中如等待玩家输入务必定期调用yield()让无线电栈有机会处理后台任务发送ACK、监听信道。降低数据传输量 虽然引擎已很高效但如果你的Move或Results类非常大接近60字节尝试简化数据结构。增加重试次数 在RF69Radio的实现中实际是RadioHead库可以尝试修改RHReliableDatagram的默认重试次数和超时时间但这不是根本解决办法。游戏逻辑不同步一方显示胜利另一方没反应调试generateResults逻辑 这是最常见的bug来源。用串口打印出秘密数字、收到的猜测数字、以及判断逻辑的每一步结果确保规则在所有边界情况下都正确。检查subType赋值 确保在游戏结束时如猜中数字generateResults返回true并且subType被正确设置为WIN_RESULTS或LOSE_RESULTS。引擎依赖这个来触发GAME_OVER状态。验证processResults处理 确保它正确读取了subType和自定义的result字段并更新了本地游戏状态和界面。编译错误“undefined reference tovtable for ...”这是C虚函数表链接错误。根本原因是你在头文件中声明了虚函数如decideMyMove()但在对应的.cpp文件中没有提供它的实现体。确保每一个声明的虚函数都有定义即使是空定义。运行不稳定偶尔死机堆栈溢出 Arduino内存有限。检查是否在函数内定义了大型数组如屏幕缓冲区。考虑使用全局变量或PROGMEM。无线电中断冲突 RF69HCW使用中断来接收数据。确保你没有在其他地方禁用全局中断cli()太长时间。电源问题 在发送无线电信号时模块会瞬间消耗较大电流可达100mA以上。如果电源内阻大或容量不足会导致电压骤降引起单片机复位。使用电容如100uF靠近模块电源引脚进行稳压。这个引擎将无线双人游戏开发中最复杂、最容易出错的部分标准化了。一旦你理解了Move、Results、Game这三个类的职责和交互方式开发新游戏就变成了纯粹的逻辑和创意工作。你可以专注于设计有趣的玩法、绘制精美的像素画、添加动人的音效而不用担心数据会不会丢包、状态会不会乱掉。这种“分离关注点”的设计正是高效嵌入式开发的精髓所在。