基于Arduino与MAX7219的经典Pong游戏复刻:从硬件连接到游戏逻辑实现

发布时间:2026/6/4 17:20:37

基于Arduino与MAX7219的经典Pong游戏复刻:从硬件连接到游戏逻辑实现 1. 项目概述与核心思路几年前我在整理工作室的零件盒时翻出了几块尘封已久的MAX7219驱动的8x8 LED点阵屏和两个游戏摇杆模块。当时就在想能不能用这些最基础的元件复刻一下上世纪70年代那款风靡全球的《Pong》游戏这个想法很简单但实现起来却是一个绝佳的嵌入式系统入门项目。它麻雀虽小五脏俱全你需要处理图形显示、实时输入、游戏逻辑、状态反馈还得考虑代码效率和可维护性。最终我决定用最普及的Arduino UNO作为大脑搭建一个双人对战的乒乓球游戏机。这个项目不追求华丽的3D画面它的魅力在于用最原始的“像素”和最简单的交互还原游戏最本质的乐趣同时让你透彻理解一个交互式电子系统是如何从无到有构建起来的。这个项目非常适合两类朋友一是刚接触Arduino和C语言编程的初学者你可以通过它建立起“输入-处理-输出”的完整概念二是有一定基础、想挑战更完整项目逻辑的爱好者。整个系统硬件成本低廉核心就是一块Arduino、四块LED点阵屏和两个摇杆。软件上我们将深入一个优秀的开源库LedControl并编写一套完整的游戏状态机。我会从硬件接线开始一步步带你理解每个引脚的作用然后深入代码讲解如何将摇杆的模拟信号转化为球拍位置如何让一个“像素点”小球在屏幕上按物理规律运动以及如何判断得分与胜负。过程中我会分享我调试时踩过的坑比如如何消除摇杆的抖动、如何优化屏幕刷新以避免闪烁、以及如何让游戏节奏更符合手感。2. 硬件选型、清单与连接详解2.1 核心硬件解析与选型理由主控单元Arduino UNO选择UNO是因为其极高的普及度和稳定性。它基于ATmega328P微控制器拥有14路数字I/O口其中6路可作PWM输出和6路模拟输入口完全满足本项目需求。对于想进一步精简体积的朋友完全可以使用Arduino Nano其引脚功能与UNO完全兼容。更硬核的玩法是使用独立的ATmega328P芯片搭建最小系统这能让你对微控制器的运行基础如晶振、复位电路有更深理解但需要额外的USB转串口模块如FT232RL进行程序烧录。显示单元MAX7219驱动的8x8 LED点阵模块共4块这是本项目的视觉核心。MAX7219是一款集成度高、驱动简单的LED显示驱动芯片。单个芯片可以驱动一个8x8的点阵屏或8个7段数码管。它的巨大优势在于支持级联Daisy-Chaining我们只需使用主控的3个数字引脚DIN CLK CS就能串联控制多达8个模块极大地节省了I/O资源。4块屏级联后我们得到了一个32x8像素的横向显示屏足够呈现球台、球拍和球的运动。注意市面上有“MAX7219点阵模块”和“8x8 LED点阵屏MAX7219驱动板”两种形式。建议直接购买前者即一个集成好的蓝色或红色模块它已经将芯片、点阵屏和必要的电阻电容集成在一块PCB上使用起来非常方便。输入单元双轴模拟摇杆模块共2个我们选用的是最常见的PS2游戏机手柄同款摇杆模块。它本质上是由两个电位器X轴和Y轴和一个按键开关组成。在本项目中我们只使用X轴VRx的模拟值来控制球拍上下移动Y轴VRy悬空不接。中间的按键SW可以预留作为发球或开始按钮。模拟输入的范围是0-1023对应电压0-5V我们需要将其映射到球拍可能出现的几个垂直位置上。其他元件压电蜂鸣器用于发出击球、得分、获胜等音效增加游戏反馈。它是可选件但强烈建议加上体验提升显著。220Ω电阻作为蜂鸣器的限流电阻保护其和Arduino的引脚。面包板与杜邦线用于原型搭建。建议使用公对公、公对母、母对母杜邦线组合以便于连接。电源在脱离电脑调试时一个5V/1A的USB电源或移动电源即可驱动整个系统。2.2 硬件连接电路图与接线表接线是项目的基础务必仔细。核心思想是利用面包板建立稳定的5V和GND电源总线所有模块的电源都从总线取电信号线则直接连接到Arduino指定的引脚。接线步骤与要点建立电源总线在面包板的长边通常将最外侧的两条竖排孔分别定义为正极5V总线红色和负极GND总线蓝色或黑色。将Arduino的5V引脚和任意一个GND引脚分别连接到这两条总线。连接左侧摇杆GND- 面包板GND总线。5V- 面包板5V总线。VRx- Arduino模拟引脚A0。用于读取球拍位置SW- Arduino数字引脚9。可选作为功能键VRy- 悬空不接。连接右侧摇杆GND- 面包板GND总线。5V- 面包板5V总线。VRx- Arduino模拟引脚A2。SW- Arduino数字引脚8。可选VRy- 悬空不接。连接LED点阵模块级联 这是最关键的一步。我们假设将4块屏从左到右从玩家B到玩家A视角依次级联。请务必确认你的模块的输入输出方向通常DIN是数据输入DOUT是数据输出用于级联下一块。第一块最右侧靠近ArduinoGND- 面包板GND总线。VCC- 面包板5V总线。DIN- Arduino数字引脚12。CS- Arduino数字引脚11。CLK- Arduino数字引脚10。第二块将其DIN连接到第一块模块的DOUT。CS和CLK引脚需要与第一块并联即第二块的CS也接Arduino的11脚CLK也接10脚。VCC和GND接总线。第三块与第四块以此类推数据线DIN像链条一样串下去而片选CS和时钟CLK信号则是所有模块共享的。连接蜂鸣器可选蜂鸣器正极通常标有“”或较长的引脚 - 串联一个220Ω电阻 - Arduino数字引脚3或其他PWM引脚用于控制音调。蜂鸣器负极 - 面包板GND总线。实操心得接线时建议使用不同颜色的杜邦线区分功能如红色-5V黑色-GND黄色-信号线。在连接共享信号线如CS CLK时可以从总线引出多根线分别接到每个模块也可以从一个模块跳到下一个模块但务必确保连接牢固。首次上电前务必再三检查电源正负极是否接反这是烧毁模块最常见的原因。3. 软件开发环境搭建与核心库解析3.1 Arduino IDE配置与LedControl库安装软件开发在Arduino IDE中进行。首先需要安装核心的显示驱动库——LedControl库。这个库由Eberhard Fahle开发它极大地简化了与MAX7219芯片的通信过程我们无需了解底层SPI时序细节只需调用高层函数即可控制每个LED的亮灭。安装方法打开Arduino IDE点击工具-管理库...。在库管理器的搜索框中输入“LedControl”。找到由“Eberhard Fahle”维护的库点击“安装”。安装完成后在文件-示例菜单中应该能看到LedControl的分类里面有很多有用的示例程序可以用来测试你的显示屏是否工作正常。库的核心对象与方法 创建一个LedControl对象是第一步LedControl lc LedControl(dataPin, clockPin, csPin, numDevices);dataPin: 连接模块DIN的引脚号本例中为12。clockPin: 连接模块CLK的引脚号本例中为10。csPin: 连接模块CS的引脚号本例中为11。numDevices: 级联的模块数量本例中为4。初始化后需要调用lc.shutdown(0, false)来唤醒所有显示屏参数0表示第一个设备false表示关闭休眠模式。然后使用lc.setIntensity(0, 8)设置亮度范围0-15。最后用lc.clearDisplay(0)清屏。控制单个LED的函数是lc.setLed(addr, row, col, state);addr: 设备地址0表示级联中的第一块屏最右边1表示第二块以此类推。row: 行号0-7。col: 列号0-7。state:true点亮false熄灭。3.2 游戏核心逻辑设计与状态机在开始写代码前我们必须规划好游戏的整体逻辑。一个好的方法是绘制一个简单的状态机State Machine它定义了游戏可能处于的不同状态以及状态之间转换的条件。对于这个乒乓球游戏我们可以定义以下几个状态待机状态显示欢迎图案或等待开始。循环检测开始按钮摇杆按键。准备状态显示双方比分球拍出现在初始位置等待玩家移动摇杆准备。发球状态球出现在发球方球拍处以初始速度等待发出例如按下按键或定时自动发出。运动状态球在运动这是游戏的主状态。在此状态下程序需要更新球的位置根据当前速度向量xSpeed, ySpeed计算下一帧球的位置。边界碰撞检测检测球是否碰到上下边界如果是则反转ySpeed反弹。球拍碰撞检测检测球是否到达左/右边界球拍位置。如果到达则判断当前球的高度是否在球拍范围内。如果在则反转xSpeed球弹回。根据球击中球拍的不同部位微调ySpeed模拟击球角度增加游戏性。增加球速xSpeed绝对值增大提高游戏难度。播放击球音效。得分判定如果球到达左/右边界但未碰到球拍则对方得分。播放得分音效进入“得分显示状态”。得分显示状态更新并显示新比分短暂停留如1秒然后判断是否有一方达到获胜分数如5分。如果达到进入“获胜状态”否则回到“准备状态”由得分方发球。获胜状态显示获胜方图案播放胜利音效长时间停留后复位游戏回到“待机状态”。将游戏分解成这些状态后我们的loop()函数就会非常清晰它只是一个大的switch-case语句根据当前状态执行相应的函数并判断是否需要跳转到下一个状态。4. 核心代码实现与分步解析4.1 全局变量定义与初始化代码开头我们需要定义所有游戏相关的变量和常量。清晰的命名和合理的组织是代码可读性的关键。#include LedControl.h // 引入显示库 // 引脚定义 #define PIN_DATA 12 #define PIN_CLK 10 #define PIN_CS 11 #define PIN_JOYSTICK_LEFT_X A0 #define PIN_JOYSTICK_RIGHT_X A2 #define PIN_BUZZER 3 // 游戏常量 const int SCREEN_WIDTH 32; // 总列数 4块屏 * 8列 const int SCREEN_HEIGHT 8; // 总行数 const int PADDLE_HEIGHT 3; // 球拍高度占几行 const int INITIAL_BALL_SPEED 1; // 球初始速度每帧移动的列数 const int WIN_SCORE 5; // 获胜分数 // 游戏变量 LedControl lc LedControl(PIN_DATA, PIN_CLK, PIN_CS, 4); // 4个显示设备 int ballX, ballY; // 球的坐标 int ballSpeedX, ballSpeedY; // 球的速度向量 int leftPaddleY, rightPaddleY; // 左右球拍的顶部行坐标 int leftScore 0, rightScore 0; // 双方比分 int gameState 0; // 游戏状态0:待机1:准备2:运动中... // 为了消除摇杆抖动和模拟信号噪声 const int JOYSTICK_DEADZONE 50; // 死区阈值 int leftPaddleTargetY 0; // 摇杆计算出的目标位置 int rightPaddleTargetY 0;这里我引入了JOYSTICK_DEADZONE死区的概念。因为模拟摇杆在中心位置附近会有微小波动直接读取会导致球拍抖动。我们将模拟值映射到0-7范围后如果变化小于死区阈值则忽略这次变化保持球拍位置不变。4.2 摇杆输入处理与球拍移动我们需要一个函数来读取摇杆并平滑地移动球拍。这里的关键是将模拟值0-1023映射到屏幕允许的球拍位置0到SCREEN_HEIGHT - PADDLE_HEIGHT。void readPaddles() { // 读取左侧摇杆 int leftRaw analogRead(PIN_JOYSTICK_LEFT_X); // 将0-1023映射到0-5因为屏幕高8球拍高3所以球拍顶部Y坐标范围是0-5 int leftMapped map(leftRaw, 0, 1023, 0, SCREEN_HEIGHT - PADDLE_HEIGHT); // 应用死区滤波只有当目标位置与当前位置差值足够大时才更新 if (abs(leftMapped - leftPaddleTargetY) (JOYSTICK_DEADZONE / 128)) { // 128是map后的近似缩放因子 leftPaddleTargetY leftMapped; } // 平滑移动每次只向目标位置移动1格避免跳跃 if (leftPaddleY leftPaddleTargetY) leftPaddleY; else if (leftPaddleY leftPaddleTargetY) leftPaddleY--; // 同理处理右侧摇杆 int rightRaw analogRead(PIN_JOYSTICK_RIGHT_X); int rightMapped map(rightRaw, 0, 1023, 0, SCREEN_HEIGHT - PADDLE_HEIGHT); if (abs(rightMapped - rightPaddleTargetY) (JOYSTICK_DEADZONE / 128)) { rightPaddleTargetY rightMapped; } if (rightPaddleY rightPaddleTargetY) rightPaddleY; else if (rightPaddleY rightPaddleTargetY) rightPaddleY--; }这个函数实现了带死区滤波和平滑过渡的摇杆控制。map函数是Arduino的核心函数之一它进行线性映射。平滑移动每次增减1让球拍移动看起来更自然而不是直接“跳”到目标位置。4.3 球体运动、碰撞检测与物理模拟这是游戏逻辑最核心的部分。我们将在updateBall()函数中实现。void updateBall() { // 1. 擦除上一帧的球 drawPixel(ballX, ballY, false); // 2. 根据速度更新位置 ballX ballSpeedX; ballY ballSpeedY; // 3. 上下边界碰撞检测 if (ballY 0 || ballY SCREEN_HEIGHT - 1) { ballSpeedY -ballSpeedY; // 反转Y方向速度 ballY ballSpeedY; // 防止卡在边界 playTone(300, 50); // 播放边界碰撞音效 } // 4. 左右边界球拍碰撞检测 // 检查是否到达左边界右侧玩家的球拍 if (ballX 1 ballSpeedX 0) { // 判断球是否击中球拍球的Y坐标是否在球拍从leftPaddleY开始高度为PADDLE_HEIGHT的范围内 if (ballY leftPaddleY ballY leftPaddleY PADDLE_HEIGHT) { ballSpeedX -ballSpeedX; // 反弹 ballX 1; // 将球置于球拍右侧防止重复检测 // 模拟击球角度根据球击中球拍的相对位置给一个垂直方向的速度分量 int hitRelativePos ballY - leftPaddleY; // 0, 1, 2 ballSpeedY hitRelativePos - 1; // 可能为 -1, 0, 1 // 每次击球后略微加 if (abs(ballSpeedX) 3) { // 设置一个最大速度上限 ballSpeedX (ballSpeedX 0) ? 1 : -1; } playTone(523, 100); // 播放击球音效C5音 } else { // 未击中右侧玩家得分 rightScore; gameState STATE_SCORE; // 切换到得分显示状态 playTone(200, 500); // 播放得分音效 return; // 跳出函数不再绘制本帧的球 } } // 同理检查右边界左侧玩家的球拍 if (ballX SCREEN_WIDTH - 2 ballSpeedX 0) { if (ballY rightPaddleY ballY rightPaddleY PADDLE_HEIGHT) { ballSpeedX -ballSpeedX; ballX SCREEN_WIDTH - 2; int hitRelativePos ballY - rightPaddleY; ballSpeedY hitRelativePos - 1; if (abs(ballSpeedX) 3) { ballSpeedX (ballSpeedX 0) ? 1 : -1; } playTone(523, 100); } else { leftScore; gameState STATE_SCORE; playTone(200, 500); return; } } // 5. 绘制新位置的球 drawPixel(ballX, ballY, true); }drawPixel是一个自定义函数它负责将全局坐标(ballX, ballY)转换为具体的MAX7219设备地址和行列坐标并调用lc.setLed()。因为我们的屏幕是4块8x8屏横向拼接所以转换逻辑是deviceIndex ballX / 8col ballX % 8。碰撞检测中的“球拍范围判断”是游戏可玩性的关键。通过hitRelativePos计算击球点在球拍上的相对位置顶部、中部、底部并据此赋予球不同的ballSpeedY实现了“削球”或“挑球”的效果大大增加了游戏的策略性和趣味性。4.4 图形绘制、分数显示与音效反馈图形绘制除了画点和球拍由连续几个点组成的竖线我们还需要在待机、得分、获胜时显示一些简单的图案比如数字、笑脸、奖杯等。这些图案可以用一个字节数组每行一个字节每位代表一个LED来定义。LedControl库提供了setRow函数可以一次设置一整行非常适合显示预定义的图案。分数显示我们可以为0-9每个数字定义一个8x8的位图数组。显示分数时根据每位数字调用对应的位图显示在左右两侧的屏幕上。例如左侧玩家的分数显示在最左边的两块屏上十位和个位右侧玩家的分数显示在最右边的两块屏上。音效反馈使用tone(PIN_BUZZER, frequency, duration)函数可以产生简单的音效。为不同事件击球、得分、获胜设置不同的频率和时长能极大提升游戏体验。注意tone函数是非阻塞的它会在后台播放不影响主循环运行。你也可以用更复杂的旋律但这需要更精细的定时控制。void playScoreTone(int player) { if (player LEFT_PLAYER) { tone(PIN_BUZZER, 392, 200); // Sol delay(200); tone(PIN_BUZZER, 523, 300); // Do } else { tone(PIN_BUZZER, 523, 200); // Do delay(200); tone(PIN_BUZZER, 392, 300); // Sol } }5. 系统调试、优化与深度扩展5.1 常见问题排查与实战技巧在项目组装和代码调试过程中你几乎一定会遇到下面几个问题。这里是我的排查清单和解决方案LED点阵屏不亮或显示乱码检查电源首先用万用表测量模块VCC和GND之间是否有稳定的5V电压。电流不足特别是4块屏全亮时也会导致显示暗淡或乱码确保你的电源能提供至少1A的电流。检查级联顺序确认DIN和DOUT的连接顺序是否正确。数据流向必须是Arduino - 第一块屏的DIN - 第一块屏的DOUT - 第二块屏的DIN - ...。接反会导致只有第一块屏有反应。检查引脚定义确认代码中LedControl lc(PIN_DATA, PIN_CLK, PIN_CS, 4)的引脚号与实际接线一致。CS引脚有时也叫LOAD或LOAD/CS。运行测试程序使用LedControl库自带的示例程序LCDemo7Segment或LCDemoMatrix快速验证硬件和基础库函数是否正常。摇杆控制不灵敏或球拍抖动死区设置这是最常见的原因。调整代码中的JOYSTICK_DEADZONE值。如果摇杆在中心位置时球拍仍轻微晃动就增大这个值比如从50调到100。如果感觉摇杆不跟手就减小这个值。模拟信号噪声在摇杆的VRx引脚和GND之间并联一个0.1uF的瓷片电容可以滤除高频噪声。在代码中也可以采用滑动平均滤波smoothedValue (alpha * rawValue) ((1 - alpha) * previousSmoothedValue)其中alpha是一个介于0和1之间的滤波系数。映射范围校准有些摇杆的物理范围达不到0-1023。可以在setup()中读取摇杆在最小和最大位置时的值然后用map(value, actualMin, actualMax, 0, 5)进行动态映射。游戏运行卡顿或球有拖影优化绘制确保你的drawPixel和drawPaddle函数只更新发生变化的部分而不是每帧重绘整个屏幕。我们的代码中球和球拍移动前先擦除旧位置再绘制新位置就是这个思想。避免使用delay()在主循环loop()中绝对不要使用长延时delay()它会阻塞一切。对于需要计时的地方如球速、状态停留使用millis()函数进行非阻塞计时。例如unsigned long previousBallUpdate 0; const int BALL_UPDATE_INTERVAL 100; // 毫秒 void loop() { unsigned long currentMillis millis(); if (currentMillis - previousBallUpdate BALL_UPDATE_INTERVAL) { previousBallUpdate currentMillis; updateBall(); // 更新球的位置 } // 其他不依赖延时的操作可以继续执行 readPaddles(); updatePaddles(); }简化逻辑检查碰撞检测等计算密集型函数确保没有冗余计算。5.2 性能优化与进阶功能扩展当基础功能稳定后你可以尝试以下优化和扩展让项目更具挑战性和个人特色帧率控制与游戏节奏使用millis()为游戏建立一个固定的时间步长如每秒30帧即每33毫秒一帧。在这个时间步长内更新所有游戏对象球、球拍的位置和状态。这能确保游戏在不同性能的Arduino板上运行速度一致。更丰富的游戏模式单人练习模式对面是一个由简单AI控制的球拍。AI可以设计成始终试图移动到球预测的Y坐标但加入一个随机误差或反应延迟以调整难度。变速球与特效随机生成一些“道具球”击中后球速突变或球拍暂时变长/变短。关卡与难度递增随着游戏进行不仅球速增加球拍高度也可以逐渐减小。硬件美化与结构设计制作外壳用激光切割亚克力板或3D打印一个外壳将面包板、Arduino和屏幕整合进去摇杆可以嵌在面板上。添加按钮除了摇杆可以增加独立的“开始/暂停”、“重置”按钮。升级显示如果你觉得8像素的高度太局促可以尝试级联更多屏幕组成一个16x16甚至更大的显示区域。但这需要修改坐标映射函数并且要注意Arduino的内存和刷新率限制。代码结构优化将游戏状态机、物理引擎、渲染引擎、输入处理模块化写成不同的类和头文件。这虽然对Arduino这样的嵌入式环境略显“重量级”但对于学习大型项目的代码组织非常有帮助。这个项目从一根线一个元件连接开始到最终两人酣战其乐趣不仅在于游玩的瞬间更贯穿于整个“创造”的过程。当你看到那个由自己编写逻辑的小光点在两个由自己焊接的摇杆控制的亮线间来回跳动时那种成就感是无替代的。它扎实地锻炼了你硬件连接、传感器信号处理、实时系统编程和简单游戏逻辑设计的能力。希望你在实现它的过程中也能感受到这种从零到一创造的快乐。如果在实现过程中遇到任何问题回溯检查电源、信号线和代码中的映射关系十有八九能解决问题。祝你玩得开心

相关新闻