公司动态

基于Arduino与SSD1306的极简Pong游戏:单编码器双控制方案与状态机实现

📅 2026/8/19 1:46:12
基于Arduino与SSD1306的极简Pong游戏:单编码器双控制方案与状态机实现
1. 项目概述用一颗旋钮和一块小屏复刻经典Pong最近在整理工作室的物料翻出来几片闲置的Adafruit SSD1306 OLED屏和一个旋转编码器。看着它们一个念头冒了出来能不能用最少的硬件做一个纯粹的、可玩的游戏Pong这个电子游戏的鼻祖瞬间就成为了最合适的目标。它规则简单交互直观但实现起来却涵盖了从硬件接口、状态机逻辑到图形渲染的完整嵌入式开发流程。这个项目就是只用一个旋转编码器和一块SSD1306 OLED屏在C环境下从零开始构建一个可运行的Pong游戏。对于嵌入式开发者或单片机爱好者来说这个项目极具吸引力。它不像那些复杂的物联网或机器人项目需要一大堆传感器和模块核心就是一颗旋钮编码器和一块小屏成本极低但“麻雀虽小五脏俱全”。你将直面几个核心挑战如何用单个输入设备控制两个玩家或人机对战如何在资源受限的微控制器上实现流畅的游戏逻辑与动画如何驱动SSD1306这块单色OLED屏来绘制游戏画面通过解决这些问题你能深入理解事件驱动编程、有限状态机FSM在游戏中的应用以及I2C/SPI通信、位图操作等底层硬件知识。整个项目的代码量不大但思维密度很高。它非常适合有一定C基础想从“点灯”进阶到“做个小东西”的开发者也适合作为大学嵌入式课程或兴趣小组的实战项目。下面我就把自己从电路连接、代码框架到调试优化的全过程拆解开来希望能给你一个清晰的实现蓝图和一堆能直接避坑的实操经验。2. 核心硬件与交互设计解析2.1 硬件选型与连接为什么是它们这个项目的硬件清单简洁到令人发指主控MCU任何一款常见的开发板都可以比如Arduino Uno/Nano、ESP32、STM32 Blue Pill等。我手头用的是Arduino Nano因其引脚够用、社区资源丰富、USB编程方便。关键是要有支持外部中断的引脚来接编码器以及I2C或SPI引脚来连接屏幕。旋转编码器我选用的是常见的EC11系列5引脚增量式编码器。它除了两个相位输出A、B相外还带一个按下功能SW。在这个项目中A、B相用于精确控制球拍上下移动按下按键则作为游戏开始/暂停/重置的功能键。这是实现单设备控制的核心。Adafruit SSD1306 OLED显示屏我使用的是128x64像素的0.96英寸版本通过I2C接口通信。选择它是因为Adafruit配套的Adafruit_SSD1306和Adafruit_GFX库极其成熟能大幅降低图形绘制的复杂度。它的高对比度和快速响应特性也非常适合动态游戏画面。接线示意图以Arduino Nano I2C SSD1306为例SSD1306-Arduino NanoVCC - 5V (或3.3V视模块版本而定)GND - GNDSCL - A5 (或SCL引脚)SDA - A4 (或SDA引脚)旋转编码器-Arduino NanoCLK (A相) - D2 (配置为外部中断引脚至关重要)DT (B相) - D3SW (按键) - D4 (配置为上拉输入) - 5VGND - GND注意1编码器消抖机械编码器在转动时会产生触点抖动导致一次物理转动被误读为多次触发。必须在软件或硬件层面进行消抖。软件上可以在中断服务程序ISR中简单的加入几毫秒的延时但会阻塞其他代码。更推荐的方式是使用状态机在ISR中只记录事件标志在主循环中处理或者使用硬件电容滤波。注意2I2C地址大多数SSD1306模块的I2C地址是0x3C但也有部分是0x3D。如果初始化屏幕失败第一件事就是用I2C扫描工具确认地址。2.2 单编码器双控制方案设计这是本项目的第一个设计难点。Pong需要两个球拍Paddle分别上下移动。只有一个编码器如何分配 我设计了两种模式并通过长按编码器按键来切换单人模式VS AI玩家控制右侧球拍编码器转动直接对应其上下移动。左侧球拍由电脑AI控制AI逻辑可以很简单比如让球拍中心跟踪球的Y坐标。双人模式交替控制这是更有趣的部分。两个玩家共用同一个编码器。当前激活的玩家球拍会高亮显示比如闪烁转动编码器即移动该玩家的球拍。当球被击回或得分后控制权自动交换到另一方。这产生了一种独特的、需要默契和预判的“回合制”操作体验大大增加了游戏的策略性和趣味性。状态机是实现此逻辑的关键。我们需要定义一个游戏状态枚举例如enum GameState { MENU, // 菜单选择模式 SINGLE_PLAY, // 单人游戏进行中 MULTI_PLAY, // 双人游戏进行中 PAUSE, // 暂停 GAME_OVER // 游戏结束显示分数 };在MULTI_PLAY状态下再定义一个子状态activePlayerLEFT 或 RIGHT用于指示当前谁拥有编码器的控制权。编码器的中断处理函数和主循环逻辑都需要根据这些状态来做出不同的响应。3. 软件架构与核心模块实现3.1 第三方库依赖与初始化为了快速上手我们利用成熟的库来处理硬件底层。Adafruit SSD1306 GFX库这是图形显示的基石。通过Arduino IDE的库管理器搜索安装即可。#include Adafruit_GFX.h #include Adafruit_SSD1306.h #define SCREEN_WIDTH 128 #define SCREEN_HEIGHT 64 #define OLED_RESET -1 // 如果屏幕有RESET引脚则接具体引脚号 Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, Wire, OLED_RESET);初始化时务必检查display.begin()的返回值这是屏幕驱动成功的标志。Encoder库可选但推荐为了更优雅、无阻塞地处理编码器我使用了Paul Stoffregen的Encoder库。它内部实现了消抖和计数你只需要关心位置变化量。#include Encoder.h Encoder myEncoder(ENCODER_A_PIN, ENCODER_B_PIN); // 初始化编码器对象 long oldPosition -999; // 用于存储旧位置如果不使用库就需要自己编写外部中断服务函数并妥善处理消抖和方向判断代码会稍显复杂。初始化流程要点void setup() { Serial.begin(115200); // 调试用 // 1. 初始化屏幕 if(!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) { Serial.println(F(SSD1306 allocation failed)); for(;;); // 卡死便于排查 } display.clearDisplay(); display.setTextSize(1); display.setTextColor(SSD1306_WHITE); display.display(); // 清屏并显示 // 2. 初始化编码器引脚如果不用库 pinMode(ENCODER_A_PIN, INPUT_PULLUP); pinMode(ENCODER_B_PIN, INPUT_PULLUP); pinMode(ENCODER_SW_PIN, INPUT_PULLUP); // 如果需要高性能可以为A相引脚配置外部中断 // attachInterrupt(digitalPinToInterrupt(ENCODER_A_PIN), encoderISR, CHANGE); // 3. 初始化游戏变量 resetGame(); currentState MENU; }3.2 游戏对象与物理建模我们需要在代码中抽象出几个核心对象球Ball、球拍Paddle、边界Boundary和分数Score。1. 球拍Paddle类 球拍本质上是一个在垂直方向移动的矩形。关键属性是位置Y坐标、高度、宽度和移动速度。class Paddle { public: int x; // 固定X坐标左侧或右侧 int y; // 当前Y坐标矩形顶部位置 int width; int height; int speed; // 每次移动的像素数 Paddle(int startX, int startY, int w, int h, int s) { x startX; y startY; width w; height h; speed s; } void moveUp() { y max(0, y - speed); // 确保不超出上边界 } void moveDown() { y min(SCREEN_HEIGHT - height, y speed); // 确保不超出下边界 } // 获取球拍的边界框用于碰撞检测 int getTop() { return y; } int getBottom() { return y height; } int getLeft() { return x; } int getRight() { return x width; } void draw(Adafruit_SSD1306 d) { d.fillRect(x, y, width, height, SSD1306_WHITE); } };2. 球Ball类 球是一个移动的点或小方块有位置x, y和速度向量vx, vy。class Ball { public: float x, y; float vx, vy; int radius; Ball(float startX, float startY, float startVx, float startVy, int r) { x startX; y startY; vx startVx; vy startVy; radius r; } void update() { x vx; y vy; } void draw(Adafruit_SSD1306 d) { d.fillCircle((int)x, (int)y, radius, SSD1306_WHITE); } // 检查与边界的碰撞上下 void checkBoundaryCollision() { if (y radius || y (SCREEN_HEIGHT - radius)) { vy -vy; // 反转Y方向速度 // 可选加入一点随机性防止垂直无限弹跳 // vy (random(-10, 11) / 100.0); } } // 检查与球拍的碰撞简化版轴对齐包围盒AABB检测 bool checkPaddleCollision(Paddle p) { // 预测球下一帧的位置进行更精确的碰撞检测 float nextX x vx; float nextY y vy; if (nextX radius p.getLeft() nextX - radius p.getRight()) { if (nextY radius p.getTop() nextY - radius p.getBottom()) { // 发生碰撞 vx -vx * 1.05; // 反弹并略微加速增加游戏紧张感 // 根据击中球拍的位置调整反弹角度模拟切球效果 float hitPos (nextY - p.y) / (float)p.height - 0.5; // -0.5 到 0.5 vy hitPos * 2.0; // 根据击中位置增加垂直分量 // 限制垂直速度防止过快 vy constrain(vy, -5.0, 5.0); return true; } } return false; } };实操心得碰撞检测的优化简单的矩形与圆检测在低速时没问题但高速下可能发生“隧道效应”球从球拍中穿过。上述代码使用了基于下一帧位置的预测性检测效果更好。另一种更精确但稍耗资源的方法是使用**分离轴定理SAT**进行连续碰撞检测但对于Pong这个简单游戏预测法已经足够。3.3 主循环与状态机驱动游戏的主循环loop()函数是状态机的调度中心。它不负责具体的、耗时的绘制或计算而是根据currentState调用相应的处理函数。void loop() { // 1. 读取输入非阻塞方式 handleEncoder(); handleButton(); // 2. 状态机调度 switch (currentState) { case MENU: stateMenu(); break; case SINGLE_PLAY: stateSinglePlay(); break; case MULTI_PLAY: stateMultiPlay(); break; case PAUSE: statePause(); break; case GAME_OVER: stateGameOver(); break; } // 3. 控制帧率避免刷新过快导致画面撕裂或MCU过载 delay(16); // 目标约60 FPS (1000ms/60 ≈ 16ms) }每个状态函数如stateSinglePlay()内部遵循“处理输入 - 更新逻辑 - 渲染输出”的模式。例如void stateSinglePlay() { // 处理AI aiUpdate(); // 更新球的位置和碰撞 ball.update(); ball.checkBoundaryCollision(); if (ball.checkPaddleCollision(leftPaddle) || ball.checkPaddleCollision(rightPaddle)) { // 碰撞音效或反馈如屏幕闪烁 } // 检查得分 if (ball.x 0) { scoreRight; resetBall(); checkGameOver(); } else if (ball.x SCREEN_WIDTH) { scoreLeft; resetBall(); checkGameOver(); } // 渲染 renderGame(); }4. 图形渲染与性能优化4.1 基于Adafruit_GFX的高效绘制SSD1306是单色屏每个像素只有开白色或关黑色。Adafruit_GFX库提供了丰富的绘图原语但不当使用会导致帧率低下。绘制策略避免全屏清屏display.clearDisplay()会清除整个显示缓冲区耗时较长。在动态游戏中可以只清除物体运动轨迹上的旧区域即“脏矩形”更新但对于Pong这种画面整体变化大的场景全屏清屏更简单。一个折中方案是双缓冲区但Arduino上内存紧张通常直接单缓冲全屏清屏。使用display.drawFastHLine/VLine代替drawLine画水平或垂直线时专用函数更快。预计算静态元素如球场中线、边界线、固定的分数显示区域等可以在游戏初始化时绘制一次后续无需重绘。我们可以在renderGame()函数中区分静态背景和动态物体。精简文本绘制display.setTextSize(2)的文本绘制很慢。分数更新时不要清除整个区域再重绘。可以在黑色背景上用白色字写旧分数即擦除再在新位置写新分数。或者更酷的方法是将数字0-9做成小位图font直接操作像素速度极快。一个高效的renderGame()函数示例void renderGame() { display.clearDisplay(); // 每帧全清简单粗暴 // 1. 绘制静态背景只在状态进入时绘制一次更好此处为示例 display.drawFastVLine(SCREEN_WIDTH/2, 0, SCREEN_HEIGHT, SSD1306_WHITE); // 中线 // 2. 绘制动态物体 leftPaddle.draw(display); rightPaddle.draw(display); ball.draw(display); // 3. 绘制分数使用自定义小字体或库函数 drawScore(scoreLeft, 10, 5); drawScore(scoreRight, SCREEN_WIDTH - 20, 5); // 4. 如果双人模式高亮当前激活玩家 if (currentState MULTI_PLAY) { if (activePlayer LEFT) { display.drawRect(leftPaddle.x-1, leftPaddle.y-1, leftPaddle.width2, leftPaddle.height2, SSD1306_WHITE); } else { display.drawRect(rightPaddle.x-1, rightPaddle.y-1, rightPaddle.width2, rightPaddle.height2, SSD1306_WHITE); } } display.display(); // 将缓冲区内容发送到屏幕 }4.2 帧率控制与游戏节奏没有帧率控制的游戏速度会受MCU性能波动影响在不同硬件上体验不一致。我们使用delay()来控制主循环周期。目标帧率经典游戏如Pong30-60 FPS足矣。delay(33)约为30 FPSdelay(16)约为60 FPS。潜在问题delay()是阻塞函数期间无法响应输入。对于编码器我们使用了中断或非阻塞的库读取所以影响不大。但如果需要处理其他实时输入可以考虑使用millis()进行非阻塞定时。使用millis()的非阻塞帧率控制示例unsigned long previousFrameTime 0; const unsigned long frameInterval 16; // 目标帧时间ms void loop() { unsigned long currentTime millis(); // 处理输入应始终快速执行非阻塞 handleEncoder(); handleButton(); // 检查是否到了该更新下一帧的时间 if (currentTime - previousFrameTime frameInterval) { previousFrameTime currentTime; // 更新游戏状态和渲染 switch (currentState) { // ... 状态处理 } } // 此处可以执行低优先级的后台任务 }5. 进阶功能与调试技巧5.1 为游戏增加“灵魂”音效与特效虽然SSD1306没有扬声器但我们可以通过视觉反馈增强游戏体验。屏幕闪烁击中球或得分时快速反转屏幕颜色display.invertDisplay(true)再恢复模拟闪光效果。void flashScreen(int durationMs) { display.invertDisplay(true); delay(durationMs); display.invertDisplay(false); }粒子特效简易球撞到球拍或墙壁时在碰撞点绘制几个短暂存在的白色像素点然后消失。这需要维护一个粒子数组并在每帧更新、绘制。通过串口输出音调如果MCU连接了蜂鸣器或有DAC可以播放简单的音效。例如用tone()函数在得分时发出一个短促的声音。5.2 调试当屏幕一片漆黑时嵌入式开发中调试往往比写代码更耗时。以下是几个关键调试点1. 屏幕不亮检查电源和接线用万用表测量VCC和GND之间电压是否为3.3V或5V。检查I2C地址运行一个I2C扫描程序Arduino IDE示例如下确认屏幕上电并地址正确。#include Wire.h void setup() { Wire.begin(); Serial.begin(115200); Serial.println(I2C Scanner...); } void loop() { byte error, address; int nDevices 0; for(address 1; address 127; address ) { Wire.beginTransmission(address); error Wire.endTransmission(); if (error 0) { Serial.print(I2C device found at address 0x); if (address16) Serial.print(0); Serial.print(address,HEX); Serial.println( !); nDevices; } } if (nDevices 0) Serial.println(No I2C devices found\n); delay(5000); }检查库和初始化确保display.begin()返回true。2. 编码器控制失灵或跳变确认中断引脚和模式CHANGE模式比RISING/FALLING能提供更精细的检测但也更容易受抖动影响。确保中断服务程序ISR尽可能短只设置标志位。软件消抖在主循环中读取编码器状态时如果使用digitalRead需要连续多次读取并判断稳定状态。使用Encoder库这是最省心的办法库内部已经处理了消抖和四倍频计数。3. 游戏逻辑问题球穿墙、反应慢串口打印关键变量将球的位置(x,y)、速度(vx,vy)、球拍位置、游戏状态等实时打印到串口监视器观察其变化是否符合预期。简化问题先注释掉AI和双人逻辑只测试一个由编码器控制的球拍和球的碰撞逐步增加复杂度。调整物理参数球的初始速度vx, vy、球拍移动速度speed、碰撞后的速度增量等都需要反复调整以达到最佳手感。太快了反应不过来太慢了又无聊。5.3 项目扩展思路这个基础框架可以衍生出许多有趣的变体增加难度等级AI的跟踪速度可以设置不同等级或者让球的移动速度随时间递增。不同的球拍类型可以设计更短但移动更快的球拍或者更宽但移动慢的球拍在双人模式下增加策略性。障碍物在球场中央随机生成移动或静止的障碍物球碰到后会随机反弹。联网对战高级如果使用ESP32或类似带Wi-Fi的MCU可以通过网络让两块开发板联机对战这涉及到网络同步、延迟补偿等更复杂的课题。更换显示设备将SSD1306换成彩色TFT屏就可以加入更丰富的色彩和图形元素。从一颗旋钮和一块小屏开始到完成一个充满乐趣的交互式游戏这个过程本身就是对嵌入式系统开发全流程的一次绝佳演练。它强迫你去思考资源分配、实时响应、人机交互等核心问题。希望这份详细的拆解能帮你绕过我踩过的那些坑顺利做出你自己的“极简Pong”。最后代码的整洁和模块化非常重要把球、球拍、渲染、输入处理都封装好未来你想添加任何新功能都会变得容易得多。