1. 项目概述:从零打造一台怀旧掌机
几年前,我在整理旧物时翻出了一台老式的游戏机,那种简单的快乐让我萌生了一个想法:为什么不自己动手做一台呢?不是去复刻那些复杂的3A大作,而是回归电子游戏的起点,做一台只玩《PONG》的掌机。《PONG》作为电子游戏的鼻祖之一,规则简单到极致——两个滑块,一个球,但其中蕴含的实时控制、碰撞检测和胜负逻辑,恰恰是理解嵌入式系统交互的绝佳范例。
这个项目的核心,就是利用ESP32这款功能强大的微控制器,驱动一块小巧的SSD1306 OLED屏幕,再配上两个实体按键,将所有东西塞进一个自己设计并3D打印的外壳里,最终做成一个可以握在手里、即插即玩的独立设备。它不仅仅是一个玩具,更是一个完整的嵌入式系统开发实践,涵盖了从电路设计、PCB打样、结构建模、固件编程到最终组装的完整流程。无论你是想深入学习ESP32开发,还是对从想法到实物的完整创造过程感兴趣,这个项目都能提供一条清晰的路径。接下来,我将毫无保留地分享整个设计与实现中的细节、踩过的坑以及最终让一切稳定运行的那些关键技巧。
2. 核心硬件选型与设计思路解析
2.1 主控芯片:为什么是ESP32?
在项目起步时,主控芯片的选择有很多,比如更经典的ATmega328P(Arduino Uno核心),或者更简单的STM32系列。但我最终选择了ESP32,主要基于以下几点考量:
首先,性能与资源充足。ESP32是一颗双核240MHz的处理器,对于驱动一个128x32像素的OLED并运行《PONG》游戏逻辑来说,堪称“性能过剩”。但这恰恰是优点,它意味着我有充足的计算资源来实现更流畅的动画、更复杂的游戏逻辑(比如未来可以增加球速变化、特效等),并且编程时几乎不需要担心优化问题。其内置的520KB SRAM和4MB Flash,也远远超过了存储程序代码和帧缓冲区的需求。
其次,极高的集成度与开发便利性。ESP32原生支持Wi-Fi和蓝牙,虽然本项目暂未使用,但为设备留下了无限的扩展可能,比如未来可以升级为双人对战版。更重要的是,其Arduino核心生态极其成熟,有大量经过验证的库(如用于驱动OLED的Adafruit_SSD1306和Adafruit_GFX),可以让我快速搭建起软件框架,将精力集中在游戏逻辑本身,而非底层驱动调试上。
最后,供电设计的灵活性。ESP32的工作电压范围是3.0V-3.6V,但通常通过其板载的LDO稳压器,可以直接从USB的5V取电。这使得整个系统的电源方案变得非常简单:一个Micro-USB接口即可解决供电和程序烧录两个问题,非常适合原型开发和手持设备。
注意:市面上ESP32开发板型号繁多,本项目选用的是“XIAO ESP32”规格。它体型非常小巧,引脚以邮票孔形式排列,非常适合集成到紧凑的自制PCB中。如果你使用像NodeMCU或DevKit V1这类带插针的板子,在PCB设计时需要留出更大的面积和通孔焊盘。
2.2 显示模块:SSD1306 OLED的优劣之辩
显示部分选择了0.96英寸、128x32分辨率的I2C接口SSD1306 OLED屏。这个选择经过了仔细权衡。
优势方面:OLED是自发光器件,每个像素独立开关,因此具有近乎无限的对比度,黑色区域完全不发光,在视觉上非常深邃,这对于游戏画面表现很有帮助。其次,它的响应速度极快,远超LCD,在显示高速运动的球体时不会有拖影。I2C接口只需要两根数据线(SDA, SCL),极大地节省了宝贵的GPIO资源。128x32的分辨率对于《PONG》这种极简风格的游戏来说足够清晰,且屏幕尺寸适中,适合做成掌机。
劣势与应对:主要的挑战在于其像素密度较高但屏幕尺寸小,在编程时,所有图形元素(球拍、球、边界、文字)都需要以像素为单位进行精确计算和绘制。例如,球拍设计为3像素宽、12像素高,球是3x3像素的正方形。在32像素高的屏幕上,球拍的移动范围需要精心计算,既要保证操作跟手,又不能超出屏幕。我通过将球拍位置变量playerPaddleY的初始值设为(SCREEN_HEIGHT - paddleHeight) / 2,来确保它一开始就处于屏幕垂直中央。
2.3 交互与结构:为“玩”而设计
交互设计上,我坚持使用两个独立的实体按键(上、下),而非摇杆或触摸板。这是为了还原最原始、最直接的操控感。按键选用的是贴片微动开关,体积小,手感明确,通过PCB直接固定,可靠性高。在软件中,我为按键设置了简单的消抖逻辑,通过检测引脚电平的稳定变化而非瞬时变化来触发动作,有效避免了误操作。
结构设计是整个项目的“骨架”,直接决定了最终产品的手感和可靠性。我使用三维建模软件(如Fusion 360)设计了分层式结构:
- 顶层板:主要作为装饰面板,开有OLED窗口和按键孔。
- 中间层:这是核心的“骨架层”,所有电子元件(ESP32、OLED、按键)都安装在这一层PCB上。
- 手柄:从世嘉MD手柄中获取灵感,设计了左右两个可握持的手柄,通过铜柱与中间层连接。
这种设计的巧妙之处在于,将功能(电路)与形态(手柄)分离。中间层承载所有电子功能,是一个完整的模块;手柄则纯粹提供人机工程学支撑。两者通过标准尺寸的M2.5铜柱和螺丝连接,不仅组装方便,而且未来如果想升级中间层的电路板,或者更换不同造型的手柄,都可以独立进行,互不影响。
3. 电路设计与PCB制作实战
3.1 原理图绘制:厘清信号与电源
PCB设计的第一步是绘制原理图,目的是理清所有元器件之间的电气连接关系。我的原理图非常简单,主要包含三个部分:
- ESP32核心电路:重点是电源。从USB口输入的5V电压,直接接入ESP32开发板的
VIN引脚,利用其板载稳压芯片产生3.3V系统电压。ESP32的3V3引脚输出,将为整个系统的其他部分(OLED、按键上拉电阻)供电。这种“单点供电”方式简化了电源树设计。 - OLED显示接口:SSD1306通过I2C通信。将ESP32的任意两个GPIO(我选择了GPIO4和GPIO5)分别定义为SDA和SCL,连接到OLED模块对应的引脚。同时,将OLED的
VCC接3.3V,GND接地。这里有一个关键细节:I2C总线需要上拉电阻,通常阻值在4.7kΩ到10kΩ之间。幸运的是,很多ESP32开发板(包括XIAO)和OLED模块本身已经在内部集成了上拉电阻,所以在原理图和PCB上可以省略。但如果遇到通信不稳定,第一个要检查的就是上拉电阻。 - 按键输入电路:两个按键的一端分别连接到ESP32的GPIO0和GPIO1,另一端共同接地。在GPIO0和GPIO1到3.3V之间,各连接一个10kΩ的电阻,这就是上拉电阻。当按键未按下时,GPIO通过上拉电阻被拉到高电平(3.3V);当按键按下时,GPIO直接与地短路,变为低电平。程序通过检测这种高低电平的变化来判定按键动作。
3.2 PCB布局与布线:从逻辑到物理
得到原理图后,就需要在PCB设计软件(我用的KiCad)中进行元器件的物理布局和连线(布线)。
布局优先原则:我首先放置了决定设备外形和安装位置的元件——OLED屏幕和两个按键。它们的位置必须严格与3D模型中的面板开孔对齐。然后,将ESP32模块放置在它们附近,以缩短走线。接口(USB)放在板子边缘方便插拔的位置。最后摆放电阻、电容等小元件。
布线实战要点:
- 电源线优先且加粗:连接
3V3和GND的走线,我将其宽度设置为0.5mm甚至更宽,以减少电阻,确保为OLED和ESP32自身提供稳定电压。 - 信号线避免直角:高频信号线(虽然I2C在本项目中频率不高,但养成好习惯)走线应使用45度角或圆弧拐角,减少信号反射。
- 为调试留出空间:我在ESP32的串口引脚(TX/RX)和几个未使用的GPIO旁引出了小的测试焊盘,方便后期用逻辑分析仪或串口调试器诊断问题。
- 覆铜增加稳定性:在布线的最后,在PCB的顶层和底层没有走线的区域全部填充铜皮,并连接到
GND网络。这能起到屏蔽干扰、稳定电源的作用。
3.3 设计验证与打样
PCB设计完成后,绝不能直接发去生产。必须进行一系列检查:
- 电气规则检查:使用软件的ERC功能,确保没有未连接的网路、短路等逻辑错误。
- 设计规则检查:设置好线宽、线距、焊盘尺寸等规则后,运行DRC,确保PCB符合制造商的生产工艺要求。
- 3D模型预览:将PCB导出为3D模型,与我设计的外壳3D模型进行装配检查,确认所有孔位、元件高度和外壳都没有干涉。
检查无误后,将设计文件导出为Gerber格式(这是PCB生产的通用语言)。我选择了黑色阻焊层搭配白色丝印,这样成品看起来更有科技感。将Gerber文件发给PCB打样厂商,通常5-7天就能收到实物。收到板子后,第一件事是目视检查和万用表通断测试,重点检查电源和地之间是否短路,各条关键信号线是否连通。
4. 软件框架与游戏逻辑实现
4.1 开发环境搭建与库管理
我使用Arduino IDE进行开发,因为它对ESP32和所需库的支持非常友好。首先需要在“开发板管理器”中添加ESP32的支持网址,并安装ESP32开发板包。然后在“库管理器”中搜索并安装以下两个核心库:
Adafruit GFX Library:这是一个强大的图形库,提供了画点、画线、画矩形、打印文字等基础函数,是图形显示的基础。Adafruit SSD1306:这是针对SSD1306系列OLED驱动的库,它基于GFX库,负责初始化屏幕、设置通信协议等底层操作。
安装好后,在代码开头通过#include <Wire.h>、#include <Adafruit_GFX.h>和#include <Adafruit_SSD1306.h>来引入它们。
4.2 游戏状态机与核心变量定义
一个游戏程序本质是一个复杂的状态机。为了清晰,我将游戏分为几个状态:START_SCREEN(开始画面)、PLAYING(游戏中)、PLAYER_WIN(玩家胜利)、PLAYER_LOSE(玩家失败)。用一个全局变量gameState来记录当前状态。
游戏的核心是数据,这些数据由一系列变量来维护:
// 屏幕与物理参数 #define SCREEN_WIDTH 128 #define SCREEN_HEIGHT 32 #define PADDLE_HEIGHT 12 #define PADDLE_WIDTH 3 #define BALL_SIZE 3 // 玩家与电脑球拍位置(Y坐标,因为球拍只上下移动) int playerPaddleY = (SCREEN_HEIGHT - PADDLE_HEIGHT) / 2; // 初始居中 int computerPaddleY = (SCREEN_HEIGHT - PADDLE_HEIGHT) / 2; // 球的位置与速度 int ballX = SCREEN_WIDTH / 2; int ballY = SCREEN_HEIGHT / 2; int ballSpeedX = 1; // 每次帧更新,球在X方向移动的像素数。正数向右,负数向左。 int ballSpeedY = 1; // 每次帧更新,球在Y方向移动的像素数。正数向下,负数向上。ballSpeedX和ballSpeedY的初始值决定了球的起始方向。通过改变它们的值(例如在碰撞后取反或增加),可以实现球的反弹和加速效果。
4.3 主循环与游戏逻辑分解
Arduino程序的loop()函数就是游戏的主循环。其逻辑结构如下:
void loop() { switch(gameState) { case START_SCREEN: drawStartScreen(); if(buttonPressed()) gameState = PLAYING; break; case PLAYING: readButtons(); // 读取玩家输入 updatePlayerPaddle(); // 更新玩家球拍位置 updateComputerPaddle(); // 更新电脑AI球拍位置 updateBall(); // 更新球的位置,并处理与边界、球拍的碰撞 drawGame(); // 绘制整个游戏画面 checkScore(); // 检查是否得分,切换游戏状态 break; case PLAYER_WIN: case PLAYER_LOSE: drawResultScreen(); delay(2000); // 显示结果2秒 resetGame(); // 重置球和球拍位置 gameState = START_SCREEN; break; } delay(16); // 控制帧率,约60FPS (1000ms/60 ≈ 16ms) }这个结构清晰地将输入、更新、渲染、状态判断分离,是游戏编程的经典模式。
4.4 关键算法详解:碰撞检测与电脑AI
1. 球与边界的碰撞:
void updateBall() { ballX += ballSpeedX; ballY += ballSpeedY; // 与上下边界碰撞 if (ballY <= 0 || ballY + BALL_SIZE >= SCREEN_HEIGHT) { ballSpeedY = -ballSpeedY; // Y方向速度取反,实现反弹 // 可以在这里加入一个简单的“哔”声效果(如果连接了蜂鸣器) } // 与玩家球拍(左侧)碰撞检测 if (ballX <= PADDLE_WIDTH && ballY + BALL_SIZE > playerPaddleY && ballY < playerPaddleY + PADDLE_HEIGHT) { ballSpeedX = abs(ballSpeedX); // 确保球速X为正(向右飞) // 可选:根据击球点调整反弹角度,增加游戏性 int hitPos = ballY - playerPaddleY; ballSpeedY = map(hitPos, 0, PADDLE_HEIGHT, -2, 2); } // 与电脑球拍(右侧)碰撞检测,逻辑类似 // ... }检测逻辑是:先判断球的X坐标是否进入球拍所在的X区域,再判断球的Y坐标范围是否与球拍的Y坐标范围有重叠。这是轴对齐边界框检测,效率很高。
2. 电脑AI的实现:一个聪明的对手能让游戏更有趣。我实现了一个简单但有效的AI:
void updateComputerPaddle() { // 目标位置是球的中心Y坐标 int targetY = ballY - PADDLE_HEIGHT/2; // 限制目标位置不超出屏幕 if (targetY < 0) targetY = 0; if (targetY > SCREEN_HEIGHT - PADDLE_HEIGHT) targetY = SCREEN_HEIGHT - PADDLE_HEIGHT; // 让电脑球拍以固定速度向目标移动,而不是瞬间移动,这样更真实 if (computerPaddleY < targetY) { computerPaddleY += 1; // 电脑移动速度,可调整以改变难度 } else if (computerPaddleY > targetY) { computerPaddleY -= 1; } }这个AI会让电脑球拍尝试去接球。通过调整移动速度(代码中的+=1或-=1),可以轻松改变电脑的难度等级。
5. 机械结构设计与3D打印实践
5.1 基于装配关系的建模思路
3D建模不是简单地画一个盒子把电路板装进去。我的思路是自顶向下设计,优先考虑整体的装配关系和用户体验。
首先,我确定了OLED屏幕和按键的开孔位置,这是人机交互的界面,必须精确。在建模软件中,我根据PCB上这两个元件的精确尺寸和位置,在代表“前面板”的零件上开出对应的矩形孔和圆形孔。
其次,设计定位与固定结构。PCB上通常有定位孔( mounting holes)。我在外壳的内壁上建模出对应的圆柱或卡扣,用于精准定位PCB。固定则使用标准的M2.5铜柱和螺丝。在顶板和中间层(承载PCB)上,我设计了对应的沉头孔,让螺丝头可以埋进去,不影响外观和手感。
最后,设计手柄形态。手柄的曲面参考了人体工学设计,确保握持时掌心有支撑,拇指能自然落在按键上。手柄与主体通过预留的接口和铜柱连接,接口处设计了加强筋,防止长期使用后断裂。
5.2 打印参数设置与后处理
模型设计好后,需要切片为3D打印机可以识别的G代码。我使用PLA材料,因为它易于打印、强度足够且无异味。
关键打印参数:
- 层高:0.2mm。这是一个在打印质量和时间之间的良好平衡点。
- 填充密度:20%。对于这种小型结构件,20%的网格填充能提供足够的强度,又不会过于耗时和耗材。
- 支撑结构:对于手柄底部可能悬空的部分,必须开启支撑。我选择“树状支撑”,它更容易拆除且更节省材料。
- 打印速度:外壁打印速度设为40mm/s,内壁和填充可以稍快(50-60mm/s),以保证外观质量的同时提升效率。
打印完成后,需要小心地移除支撑材料。可以使用钳子或专用工具。然后,用细砂纸(例如400目、800目)对结合面(如手柄与主体的连接面)进行轻微打磨,确保装配时平整、无毛刺干涉。如果追求完美外观,还可以进行补土、喷漆等后处理,但作为原型,打磨已足够。
6. 系统集成、组装与调试
6.1 焊接与模块化组装
PCB到手并验证无误后,开始焊接。对于贴片元件(如微动开关),我使用了焊锡膏+热风枪的回流焊方式:
- 用针筒将少量焊锡膏点到每个焊盘上。
- 用防静电镊子将元件精确放置到位。
- 使用热风枪,以环绕加热的方式均匀加热PCB,直到看到焊锡融化、元件自动“归位”到焊盘中央(表面张力作用),然后移开热风枪,等待冷却。
对于通孔元件(如排母、OLED屏的插针),则使用电烙铁进行手工焊接。焊接OLED时要格外小心,引脚很细密,避免连锡。一个重要的技巧:可以先焊接排针到OLED上,再将这个整体插到PCB的焊盘孔里进行焊接,这样更容易对齐。
组装顺序遵循从内到外的原则:
- 先将铜柱拧到中间层PCB的背面。
- 将焊接好所有元件的中间层PCB,通过铜柱与顶层面板固定。
- 将3D打印的按键帽(Switch Actuator)从面板内侧塞入按键孔。
- 将左右手柄套在两侧的铜柱上,从背面用螺丝固定。
- 最后,将OLED屏插入排母,将ESP32开发板插入其对应的排母。
这种模块化组装方式,使得任何一部分需要维修或更换时,都可以独立拆下,非常方便。
6.2 上电调试与问题排查
首次上电前,务必再次用万用表检查电源与地之间是否短路。连接USB线后,观察ESP32板载的电源指示灯是否正常亮起。
常见问题与排查:
屏幕不亮:
- 检查供电:用万用表测量OLED的VCC引脚是否有3.3V电压。
- 检查I2C地址:SSD1306的常见地址是
0x3C。在代码display.begin(SSD1306_SWITCHCAPVCC, 0x3C);中确认地址是否正确。有些模块可能是0x3D。可以运行一个I2C扫描程序来查找设备地址。 - 检查接线:确认SDA、SCL线是否接反,是否接触不良。
按键无反应:
- 检查上拉电阻:确保GPIO模式设置为
INPUT_PULLUP,或者外部上拉电阻已正确焊接。 - 检查接地:按键的另一端必须可靠接地。
- 软件消抖:在
readButtons()函数中,不要只检测一次电平。可以采用以下方式:if (digitalRead(buttonPin) == LOW) { // 检测到按下 delay(50); // 等待一段时间,避开机械抖动 if (digitalRead(buttonPin) == LOW) { // 再次确认 // 执行按键动作 } }
- 检查上拉电阻:确保GPIO模式设置为
游戏运行卡顿:
- 检查主循环延迟:
loop()末尾的delay()值决定了帧率。delay(16)对应约60FPS,如果设置过长(如delay(100)),游戏会显得很卡。 - 优化绘图:
Adafruit_GFX库的display.display()函数会更新整个屏幕,比较耗时。可以尝试使用display.startWrite()和display.endWrite()来优化连续绘图操作,或者只更新画面中变化的部分(双缓冲),但对于这个小游戏,全屏更新完全能胜任。
- 检查主循环延迟:
结构装配问题:
- 按键卡滞:检查3D打印的按键帽尺寸是否精确,与面板孔洞和微动开关按钮的配合是否过紧。可以适当用砂纸打磨按键帽侧面。
- 螺丝孔对不齐:检查3D模型中的孔位与PCB的孔位是否完全一致。在建模时,最好将PCB的DXF文件导入作为参考图来绘制安装柱。
完成所有调试后,一台完全自制、功能完整的《PONG》游戏掌机就诞生了。通过USB供电,即可随时开始一场复古的对决。这个项目最大的成就感,不在于复刻了一个游戏,而在于亲手将代码、电路和结构融合成一个有温度、可交互的实体。它证明了,即使是最简单的硬件和逻辑,也能创造出纯粹的乐趣。