
1. 项目概述用Arduino Nano复刻经典打砖块几年前我在整理一堆旧电子元件时翻出了一块Arduino Nano和一块闲置的SH1106 OLED屏。当时就想能不能用这些简单的硬件复刻一下童年记忆里的经典打砖块游戏这个念头一起就停不下来了。经过几周的折腾从画电路图、焊板子到一行行敲代码调试一个完整的、可玩的游戏机还真让我给做出来了。这个项目远不止是让几个像素块在屏幕上弹来弹去那么简单。它本质上是一个典型的嵌入式系统综合应用案例涵盖了微控制器MCU编程、I2C总线通信、数字输入处理、状态机逻辑、数据持久化存储以及简单的游戏物理引擎设计。对于刚接触Arduino或者想从简单传感器项目进阶到综合性应用的开发者来说这是一个绝佳的练手项目。你不仅能得到一个有趣的游戏成果更能深入理解如何让软件逻辑与硬件资源高效协同工作。整个系统的核心是Arduino Nano它负责运行所有的游戏逻辑。SH1106 OLED显示屏通过I2C接口与Nano通信以极低的引脚占用率仅需2根数据线呈现出清晰的游戏画面。六个轻触按键作为输入设备提供了左右移动、发射球体、暂停等控制功能。游戏的核心逻辑包括球体的运动轨迹计算、与挡板和砖块的碰撞检测、分数计算与等级提升全部由Nano上运行的代码实现。此外我们还利用Nano内置的EEPROM来保存历史最高分即使断电数据也不会丢失。下面这张图清晰地展示了系统中各硬件模块与Arduino Nano的连接关系你可以先有个整体印象flowchart TD subgraph A [输入与控制单元] B1[左移按键] B2[右移按键] B3[发射/开始键] B4[暂停键] B5[备用键1] B6[备用键2] end subgraph C [核心处理与存储单元] D[Arduino NanobrATmega328P MCU] E[内部EEPROMbr存储最高分] end subgraph F [输出与反馈单元] G[SH1106 OLED显示屏br128x64像素] H[无源蜂鸣器br音效反馈] end A -- 数字输入引脚 D2-D7 -- C C -- I2C总线SDA, SCL -- F C -- PWM/数字引脚 D8 -- F接下来我将从硬件选型、电路搭建到代码逻辑的逐层剖析再到调试中遇到的坑和解决技巧为你完整重现这个项目的构建过程。无论你是想照做一套还是借鉴其中的某些设计思路相信都能有所收获。2. 核心硬件选型与电路设计解析2.1 为什么是Arduino Nano与SH1106 OLED在项目启动时主控和显示设备的选择是首要问题。我选择Arduino Nano主要基于以下几点考量尺寸与接口Nano的体型非常小巧非常适合嵌入到自制的小型设备中。它保留了完整的数字和模拟IO口并且自带USB转串口芯片编程和供电一根Micro-USB线就能解决省去了额外购买FTDI编程器的麻烦。性能足够核心的ATmega328P单片机运行在16MHz对于打砖块这类2D游戏逻辑包括屏幕刷新、碰撞检测、输入响应来说绰绰有余。其32KB的Flash和2KB的RAM也足以容纳游戏代码和运行时数据。生态成熟Arduino庞大的社区和库支持是关键。例如后续用到的Arduboy2图形库就是基于Arduino平台开发的可以极大简化我们的编程工作。对于显示设备我选择了SH1106驱动的0.96寸OLED屏而非更常见的SSD1306原因如下驱动芯片差异SH1106支持132x64的显存而SSD1306是128x64。这意味着SH1106在横向有4个像素的偏移通常我们只使用中间的128x64区域。一些为SSD1306写的库直接用在SH1106上会导致显示错位需要选择兼容的库或进行偏移设置。对比度与功耗OLED是自发光对比度极高黑色部分完全不发光在显示游戏这种高对比度图形时效果远胜于LCD。而且其功耗极低整个屏幕点亮时电流也仅在20-40mA左右非常适合电池供电的场景。接口简化我选用的是I2C接口的版本只需要连接SDA数据、SCL时钟、VCC电源、GND地四根线极大节省了Nano的IO口资源。相比之下并口或SPI接口的屏幕需要占用6-7个IO口。2.2 电路连接详解与“省线”技巧根据项目描述我们需要连接1块OLED屏、6个按键和1个蜂鸣器。如果随意连接可能会用掉大量IO口。这里的设计体现了嵌入式开发中资源规划的重要性。核心连接表如下组件引脚连接至 Arduino Nano 引脚说明SH1106 OLEDSDAA4I2C数据线。注意在Nano上A4和A5除了模拟输入功能也是固定的I2C引脚。SCLA5I2C时钟线。VCC5V供电。确保你的OLED模块支持5V有些是3.3V的。GNDGND共地。按键1-6一端D2 - D7将6个按键分别连接到6个不同的数字引脚。另一端GND所有按键的另一端并联后接地。这是“下拉”设计。有源蜂鸣器正极()D8通过一个数字引脚控制发声。负极(-)GND接地。关键细节与避坑指南按键上拉电阻在代码中我们将按键引脚模式设置为INPUT_PULLUP。这意味着单片机内部会启用一个上拉电阻将引脚电平默认拉高。当按键按下时引脚直接接地电平被拉低我们通过检测LOW电平来判断按键按下。这样省去了外部物理上拉电阻让电路更简洁。I2C地址常见的SH1106 I2C地址是0x3C有时是0x3D。如果上传代码后屏幕无显示首先检查地址是否正确。可以使用I2C扫描示例代码来确认设备地址。蜂鸣器类型这里使用的是有源蜂鸣器内部带振荡电路给高电平就响控制简单。如果是无源蜂鸣器则需要通过PWM输出不同频率来产生音调控制方式不同代码也需要调整。电源去耦在Arduino Nano的5V和GND之间靠近芯片的位置建议焊接一个0.1uF的瓷片电容。这可以滤除电源线上的高频噪声防止在屏幕刷新或蜂鸣器发声时造成单片机复位或程序跑飞。这是保证系统稳定性的一个小技巧。电路搭建时使用一块洞洞板Zero PCB将所有元件固定并焊接会比用面包板更可靠避免因接触不良导致游戏过程中按键失灵或显示异常。3. 软件架构与核心代码深度剖析拿到一个上千行的完整代码直接看容易懵。最好的方式是先理解它的整体架构再深入每个模块。这个打砖块游戏的代码结构清晰是典型的状态机驱动的前后台系统。3.1 程序主循环与状态管理游戏的灵魂在于loop()函数它以一个稳定的帧率这里设为40 FPS不断循环执行。其核心逻辑可以用以下状态图来表示flowchart LR A[开机/复位] -- B{主循环开始}; B -- C[显示标题屏幕]; C -- D{按下FIRE键?}; D -- 否 -- E[显示高分榜]; E -- D; D -- 是 -- F[初始化游戏br绘制关卡]; F -- G{生命值0?}; G -- 是 -- H[游戏进行状态]; H -- I{砖块被全部击破?}; I -- 是 -- J[关卡升级]; J -- F; I -- 否 -- K{球是否掉落底部?}; K -- 是 -- L[生命值减1]; L -- G; K -- 否 -- H; G -- 否 -- M[游戏结束状态]; M -- N{分数0?}; N -- 是 -- O[录入高分]; O -- C; N -- 否 -- C;让我们结合代码看关键点帧率控制if (!(arduboy.nextFrame())) return;这行代码是游戏流畅的关键。它确保无论循环内的逻辑执行多快屏幕刷新和逻辑更新都严格按40FPS进行避免游戏速度因处理器负载不同而变化。状态切换start、initialDraw、lives等布尔变量和计数器共同构成了游戏的状态机。例如while (!start) { ... }循环实现了在标题画面和高分榜之间的切换直到玩家按下开始键。游戏核心逻辑在lives 0的分支中依次调用drawPaddle(),drawBall()并检查暂停和关卡完成条件。这里的顺序很重要先擦除旧位置再计算新位置最后在新位置绘制这是实现动画的基础。3.2 物理引擎核心球的运动与碰撞检测moveBall()函数是游戏物理的核心也是最容易出bug的地方。它的逻辑分两部分球已发射后的运动和球未发射时跟随挡板。运动计算if(released) { // 处理双倍速逻辑 if (abs(dx)2) { xb dx/2; if (tick%20) // 每两帧移动一次实现1.5倍速效果 xb dx/2; } else { xb dx; // 正常速度 } yb yb dy; }这里有一个精巧的设计dx和dy是球在X和Y轴的方向向量值通常为1或-1。但代码中出现了abs(dx)2的判断这是为了实现“加速球”的效果。当球从挡板边缘反弹时dx可能被设置为2或-2使其水平移动更快。但为了不让速度过快实际位移是dx/2并且通过tick%20控制每两帧才执行一次额外的dx/2移动综合效果是1.5倍速。这是一种在整数运算下实现非整数速度的常用技巧。碰撞检测 碰撞检测采用了**轴对齐包围盒AABB**算法这是2D游戏中最高效和常用的方法。计算包围盒为球和每个砖块计算其左、右、上、下边界。leftBall xb; rightBall xb 2; // 球宽2像素 topBall yb; bottomBall yb 2; // 球高2像素 leftBrick 10 * column; rightBrick 10 * column 10; // 砖块宽10像素 topBrick 6 * row 1; bottomBrick 6 * row 7; // 砖块高6像素检测重叠判断两个矩形是否相交。if (topBall bottomBrick bottomBall topBrick leftBall rightBrick rightBall leftBrick) { // 发生碰撞 }确定反弹方向这是碰撞响应中最容易出错的部分。代码通过比较球和砖块的边界来判断是水平碰撞还是垂直碰撞。if (bottomBall bottomBrick || topBall topBrick) { dy -dy; // 垂直方向反弹 } if (leftBall leftBrick || rightBall rightBrick) { dx -dx; // 水平方向反弹 }一个重要技巧变量bounced用于防止“双重反弹”的bug。在一次移动中球可能与多个砖块像素重叠。如果没有这个标志位球可能会在同一个moveBall()周期内对同一个碰撞反弹两次导致轨迹异常。bounced确保一次移动只处理一次反弹。挡板碰撞与“加旋”效果 球与挡板的碰撞不仅是简单的垂直反弹还加入了模拟“旋球”的效果让游戏更有趣dx ((xb-(xPaddle6))/3); // 根据击中挡板的位置改变水平速度 if (dx 0) { dx (random(0,2) 1) ? 1 : -1; // 防止出现垂直弹跳的死角 }(xPaddle6)是挡板的中心点。(xb-(xPaddle6))计算球击中点相对于挡板中心的偏移。击中左侧结果为负dx变为负向左飞击中右侧结果为正dx为正向右飞。除以3是为了减弱这个影响避免角度过于极端。这个简单的公式极大地增加了游戏的可控性和策略性。3.3 图形渲染在单色OLED上“作画”SH1106是单色屏每个像素只有亮1或灭0两种状态。Arduboy2库为我们封装了底层驱动提供了方便的绘图函数。但理解其原理对优化性能很有帮助。双缓冲与显示更新Arduboy2库使用了一个内存中的显示缓冲区arduboy.sBuffer。我们所有的drawPixel、drawRect等操作都是修改这个缓冲区。在loop()的最后调用arduboy.display()才将整个缓冲区的内容通过I2C一次性发送到OLED屏。这种方式避免了直接操作屏幕时的闪烁问题称为双缓冲。“擦除-重绘”动画模式 观察drawBall()函数void drawBall() { // 1. 在旧位置用黑色(0)绘制相当于擦除 arduboy.drawPixel(xb, yb, 0); arduboy.drawPixel(xb1, yb, 0); arduboy.drawPixel(xb, yb1, 0); arduboy.drawPixel(xb1, yb1, 0); // 2. 计算球的新位置 moveBall(); // 3. 在新位置用白色(1)绘制 arduboy.drawPixel(xb, yb, 1); arduboy.drawPixel(xb1, yb, 1); arduboy.drawPixel(xb, yb1, 1); arduboy.drawPixel(xb1, yb1, 1); }这就是经典的动画原理在下一帧绘制前先在物体的旧位置用背景色黑色重画一次以擦除它然后在计算得到的新位置用前景色白色画出来。挡板的绘制drawPaddle()也采用了同样的模式。这种方式在资源有限的单片机上非常高效。3.4 数据持久化EEPROM存储高分榜ATmega328P内部有1KB的EEPROM数据掉电不丢失非常适合存储游戏最高分这类小数据。代码中实现了一个完整的高分榜系统。数据结构设计 每个高分记录占用5字节3字节存放玩家 initials缩写2字节存放分数unsigned int范围0-65535。整个文件EE_FILE定义为2存储7条记录。// 计算EEPROM起始地址 int address file * 7 * 5 EEPROM_STORAGE_SPACE_START; // 读取分数2字节 hi EEPROM.read(address (5*i)); lo EEPROM.read(address (5*i) 1); score (hi 8) | lo; // 将两个字节合并为一个16位整数 // 读取缩写3字节 initials[0] (char)EEPROM.read(address (5*i) 2); ...高分插入算法enterHighScore函数实现了高分的有序插入。它遍历EEPROM中的7条记录将新分数与已有分数比较。当找到第一个比新分数低的记录时就将该记录及其后面的所有记录整体后移一位最后一条被挤出丢弃然后将新记录插入到这个空位。for(byte j i; j 7; j) { // 1. 读出当前槽位的旧记录 // 2. 将新记录写入当前槽位 EEPROM.update(address (5*j), ((score 8) 0xFF)); ... // 3. 将读出的旧记录作为“新记录”准备写入下一个槽位 score tmpScore; initials[0] tmpInitials[0]; ... }注意这里使用了EEPROM.update()而非EEPROM.write()。update方法会先检查目标地址的值是否与新值相同只有不同时才执行写入操作。EEPROM的每个单元有约10万次的擦写寿命使用update可以避免不必要的写入延长寿命。初始化判断 新芯片的EEPROM内容全是0xFF。代码通过判断读出的两个字节是否都是0xFF来判断该记录是否为空缺。if ((hi 0xFF) (lo 0xFF)) { tmpScore 0; // 视为0分 }4. 从零开始的完整实现流程4.1 开发环境搭建与库安装安装Arduino IDE从Arduino官网下载并安装最新版IDE。安装后打开“首选项”在“附加开发板管理器网址”中添加https://arduboy.github.io/board-support/package_arduboy_index.json。这是为了安装Arduboy相关的板卡支持。安装板卡支持包在“工具”-“开发板”-“开发板管理器”中搜索“Arduboy”安装“Arduboy by Team A.R.G.”。这个包包含了编译所需的所有核心文件。安装库文件在“项目”-“加载库”-“管理库”中搜索“Arduboy2”选择“Arduboy2 by Scott Allen”并安装。这是本游戏依赖的核心图形库。驱动安装Windows用户重点许多国产Arduino Nano使用的是CH340或CP2102 USB转串口芯片。你需要根据你的Nano版本安装对应的驱动程序否则电脑无法识别设备。CH340驱动在网上很容易搜到。4.2 代码编写、编译与上传新建项目与选择板卡在Arduino IDE中新建一个项目。在“工具”菜单下开发板选择“Arduino Nano”。处理器选择“ATmega328P (Old Bootloader)”。如果上传失败可以尝试换成“ATmega328P”。端口选择识别到的COM口连接Nano后才会出现。复制代码将提供的完整代码复制到新建的.ino文件中。编译验证点击左上角的“√”进行编译。首次编译可能会较慢因为需要编译整个Arduboy2库。确保没有错误。上传点击“→”箭头将代码上传到Nano。上传时Nano上的RX/TX指示灯会闪烁。看到“上传成功”的提示即可。4.3 硬件连接检查与上电测试按照第2部分的电路图连接所有元件。务必在连接前断开USB供电。连接完成后再插上USB线。此时OLED屏幕应该亮起并显示“BREAKOUT”标题画面。按下连接在D2或D3引脚的按键对应代码中的A/B按钮游戏应该开始。5. 调试实录与常见问题排查在实际制作过程中我遇到了不少问题。这里把典型的坑和解决方案总结出来希望能帮你节省时间。5.1 问题排查速查表现象可能原因排查步骤与解决方案OLED屏幕不亮或白屏1. 电源接反或电压不对。2. I2C地址错误。3. 初始化代码问题。1. 用万用表检查VCC和GND确认5V供电。检查接线是否松动。2. 运行I2C扫描示例代码确认屏幕地址是0x3C还是0x3D并在arduboy.begin()前通过arduboy.i2cAddr设置如果需要。3. 确认代码中包含了正确的库#include Arduboy2.h且setup()中调用了arduboy.begin()。屏幕有显示但内容错乱、花屏1. 屏幕驱动芯片不匹配SH1106 vs SSD1306。2. I2C通信受到干扰。1.Arduboy2库默认可能针对SSD1306优化。尝试在setup()中加入arduboy.setDisplayFlags(SH1106_SWITCHCAPVCC);或寻找专门兼容SH1106的库分支。2. 缩短I2C连线SDA, SCL并在两条线上各加一个4.7kΩ的上拉电阻到VCC3.3V或5V与屏幕电压一致。这是I2C总线稳定的关键按键无反应或反应混乱1. 引脚定义错误。2. 内部上拉未启用或按键另一端未接地。3. 按键抖动。1. 检查代码中RIGHT_BUTTON,LEFT_BUTTON等定义是否与你的实际接线D2-D7对应。在Arduboy2库中这些是预定义的但如果你改了引脚需要修改库文件或代码。2. 确认代码中使用了INPUT_PULLUP模式并且按键另一端可靠接地。3. 代码中已经通过oldpad,pad等变量实现了简单的“边沿检测”防止长按重复触发。如果仍有抖动可考虑在硬件上并联104电容或在软件中增加延时去抖。游戏运行卡顿、不流畅1. 帧率设置过高。2. 碰撞检测等计算过于耗时。3. I2C通信速度慢。1. 降低FRAME_RATE如从40改为30。2. 优化代码例如砖块碰撞检测是双重循环可以尝试在砖块被击中后立即跳出内层循环break。3.Arduboy2库的display()函数会刷新整个屏幕缓冲区。确保只在必要的时候调用它。蜂鸣器不响或声音异常1. 蜂鸣器正负极接反。2. 引脚输出能力不足。3. 频率或时长参数错误。1. 有源蜂鸣器有正负极之分长脚为正。2. Arduino引脚驱动电流有限约20mA。如果蜂鸣器工作电流较大可能需要接三极管驱动。本项目的蜂鸣器声音小直接驱动可行。3. 检查playTone函数中频率参数是否在可听范围20-20000Hz常见游戏音效在200-1000Hz。高分无法保存1. EEPROM地址冲突或损坏。2. 读写逻辑错误。1. 尝试更换EE_FILE的值如改为3避免与其他程序或库使用的EEPROM区域冲突。2. 使用Arduino IDE自带的EEPROM示例程序先测试简单的读写确认EEPROM本身是好的。注意EEPROM.update和EEPROM.write的区别。5.2 我踩过的几个“坑”与解决方案“鬼影”问题球或挡板移动时后面有拖影。原因这是“擦除-重绘”逻辑不彻底导致的。在drawBall()中我们只擦除了球本身2x2像素但如果球移动路径上还有其他元素比如分数文本的边缘就可能擦不干净。解决在每帧开始绘制前调用arduboy.clear()是最彻底的但会带来全屏闪烁。更优的方案是采用“局部重绘”。在这个游戏中因为背景是全黑的且元素简单我们可以在drawPaddle和drawBall时用drawRect精确擦除旧位置更大一点的范围比如旧位置周边多1个像素。但代码当前的方式在大多数情况下是够用的。球卡在边界或砖块里原因碰撞检测后的位置修正逻辑有瑕疵。例如球检测到与顶部碰撞yb 0后代码设置yb 2;。这确保了球被“推”出边界但如果球的速度很快dy绝对值大可能一帧就穿过了很厚的墙体简单的yb 2可能不足以让它完全脱离。解决更健壮的做法是根据碰撞方向和球的半径进行修正。例如与顶部碰撞后应设置yb ballRadius;本例中半径为1所以是yb 1;。同时检查dx和dy的值确保它们不会导致球在一帧内移动超过其自身尺寸否则就可能“穿透”。可以给球的移动速度设置一个上限。随机数总是相同原因random()函数需要随机数种子。如果每次开机都从同一个种子开始生成的随机序列就一样。解决代码中已经使用了arduboy.initRandomSeed();它利用未连接的模拟引脚上的浮空噪声来生成随机种子这是最佳实践。确保你没有在loop()中反复调用它只需在setup()中调用一次。6. 性能优化与功能扩展思路当基本功能实现后你可以尝试以下优化和扩展让游戏更完善或挑战更高难度。6.1 性能优化建议减少全局变量当前很多变量如dx, dy, xb, yb都是全局变量。虽然方便但不利于模块化和内存管理。可以考虑将相关的变量封装到struct结构体中例如一个Ball结构体包含位置、速度等属性。优化碰撞检测目前的碰撞检测是遍历所有砖块4行 x 13列 52个。可以引入“空间划分”思想例如只检测球所在区域周边的砖块。或者为砖块数组增加一个“活动”状态只遍历未被击中的砖块代码中已通过isHit数组实现这是好的。使用PROGMEM存储常量像砖块位置、关卡地图这类不变的数据可以存储在FlashPROGMEM中而不是RAM中节省宝贵的RAM空间。对于更复杂的游戏这非常有用。const uint8_t levelMap[4][13] PROGMEM { {1,1,1,1,1,1,1,1,1,1,1,1,1}, // ... 其他行 }; // 读取时使用 pgm_read_byte(levelMap[row][column])6.2 功能扩展创意多球与道具系统增加一个“多球”道具击中后从挡板发射第二个球。这需要修改数据结构用数组来管理多个球的状态位置、速度。不同类型的砖块坚固砖块需要击中多次才会消失。加速砖块击中后球速暂时增加。炸弹砖块击中后爆炸清除周围一圈砖块。 这需要为isHit数组扩展为存储更多信息的结构例如brickHealth、brickType。关卡编辑器与更多关卡将关卡数据砖块布局、类型设计成易于修改的数组或外部文件。甚至可以做一个简单的PC端关卡编辑器生成代码直接粘贴到Arduino中。音效与音乐利用BeepPin1库可以制作更丰富的音效比如不同材质砖块的撞击声、升级时的欢呼声。甚至可以尝试播放简单的背景音乐需要复杂的时序控制可能会影响主循环。无线对战如果使用Arduino Nano 33 IoT或ESP8266/ESP32这类带无线功能的板卡可以实现双机对战一个设备生成球和砖块另一个设备控制挡板通过无线通信同步状态。这个基于Arduino Nano的打砖块项目就像一把钥匙打开了一扇通往嵌入式游戏开发的大门。它麻雀虽小五脏俱全涵盖了从硬件接口、实时系统、图形渲染到数据存储的完整链条。调试过程中看着像素小球在亲手焊接的电路驱动的屏幕上跳跃每一次成功的碰撞和消除bug的瞬间都是对开发者最好的奖励。希望这份详细的解析能帮助你不仅复现这个游戏更能理解其背后的设计思想并在此基础上创造出属于自己的独特作品。