Arduino猜词游戏开发:从硬件搭建到状态机逻辑实现
1. 项目缘起:为什么用Arduino做“猜词游戏”?
你可能玩过很多次“Hangman”(猜词游戏),就是那种给你几个字母的空位,让你猜一个单词,猜错一次就画一笔绞刑架小人,直到小人画完或者你猜出单词为止的经典游戏。但你想过没有,把这个游戏从纸上搬到现实世界,用一块小小的Arduino开发板、几个按钮和一块屏幕来实现,会是什么感觉?这不仅仅是把游戏电子化那么简单,它背后涉及了嵌入式开发中几个非常核心且有趣的概念:如何用有限的硬件资源处理用户交互、如何管理游戏状态、以及如何将抽象的代码逻辑与具体的物理世界(按钮、屏幕)连接起来。
我最初想做这个项目,是因为想找一个能综合运用Arduino基础知识的练手项目。它不像简单的流水灯那样单调,也不像复杂的机器人那样庞大。它需要你思考输入(玩家按键)、处理(游戏逻辑判断)、输出(屏幕显示)的完整闭环。更重要的是,它能让你直观地感受到,你写的每一行代码是如何直接影响“游戏体验”的。当玩家按下按钮,屏幕上的字母被点亮,或者绞刑架多了一笔时,那种即时反馈的成就感,是纯软件模拟无法比拟的。对于刚入门Arduino的朋友来说,这是一个绝佳的、能让你看到自己进步的项目。
从技术角度看,这个项目会覆盖到Arduino编程的多个方面:数字输入(读取按钮)、字符与字符串处理(管理单词和猜测状态)、条件判断与状态机(控制游戏流程)、以及如何驱动显示器(如LCD1602或OLED)来呈现图形化信息。我们甚至可以利用蜂鸣器来增加音效,让体验更完整。下面,我就带你从零开始,一步步拆解如何用Arduino Uno打造一个属于你自己的、可玩性十足的Hangman游戏机。
2. 核心硬件选型与电路搭建思路
做任何硬件项目,第一步永远是“手里有什么”和“需要什么”。对于Arduino Hangman游戏,我们可以根据复杂度分为基础版和增强版。这里我们先从最核心、最必要的部件讲起。
2.1 大脑与骨架:主控与显示方案
主控芯片:Arduino Uno R3这是最经典的选择,没有之一。它基于ATmega328P微控制器,有14个数字I/O口和6个模拟输入口,对于我们的游戏来说绰绰有余。其32KB的Flash内存足够存储程序代码和几十个单词库,2KB的SRAM也足以处理游戏运行时的变量。更重要的是,它的社区支持最完善,任何你遇到的问题几乎都能找到答案。对于初学者,我强烈建议从Uno开始,它能让你把精力集中在逻辑上,而不是纠结于复杂的板载外设。
显示方案:字符型LCD1602 vs 图形点阵OLED这是影响游戏外观和编程复杂度的关键选择。
LCD1602液晶屏:这是最经济、最经典的选择。它能显示16列x2行共32个字符。对于Hangman游戏,我们可以用第一行显示被猜测单词的当前状态(例如
_ _ _ _ _),用第二行显示已经猜过的错误字母,或者显示简单的提示信息。它的优点是接口简单(通常使用I2C转接板后只需4根线:VCC, GND, SDA, SCL),编程有成熟的LiquidCrystal_I2C库支持,非常稳定。缺点是显示内容受限,我们无法绘制那个经典的“绞刑架小人”,只能用字符(比如(X_X))来简单表示,或者用文字提示“还剩X次机会”。0.96寸OLED显示屏(SSD1306驱动):这是我个人更推荐的选择,虽然价格稍高,但体验提升巨大。这种屏幕通常是128x64像素的分辨率,这意味着我们可以用像素点来真正绘制出绞刑架和小人!游戏的视觉表现力会直接上升一个档次。它同样通过I2C接口与Arduino通信,使用
Adafruit_SSD1306和Adafruit_GFX库可以轻松实现画线、画圆、显示文本。虽然编程上比纯文本显示稍复杂,但带来的成就感是完全不同的。
注意:无论选择哪种屏幕,务必确认其通信接口。购买时选择“I2C接口”的模块会极大简化接线。对于LCD1602,通常需要额外购买一个蓝色的I2C转接板焊接到屏幕背面。
2.2 玩家的双手:输入方案设计
玩家如何输入字母?这里有几个方案,各有优劣。
矩阵键盘(4x4):这是最接近传统游戏体验的方案。一个键盘上有0-9数字键和A-F字母键,我们可以将其映射为A-Z的字母。优点是输入明确,有实体按键的反馈感,且只需占用8个数字I/O口(4行+4列)。你需要学习
Keypad库的使用。缺点是字母排列可能不够直观(需要记忆映射),且键盘本身需要一定空间。多个独立按键:这是最直观但最占资源的方案。如果你想做26个字母键加一个确认/开始键,那需要27个I/O口,这远远超过了Uno的能力。通常的变通方法是只做少数几个导航键(例如“上/下”选择字母,“确认”猜测),通过屏幕上的一个滚动字母表来选择字母。这只需要3-4个按钮,节省了I/O口,但操作步骤变多了。
旋转编码器 + 1个确认键:这是一个非常优雅的方案。旋转编码器可以顺时针/逆时针旋转来在屏幕上的字母表中上下选择字母,按下编码器本身作为“确认”键。这样只需要占用3个数字I/O口(两个方向信号+一个按键信号),且操作非常流畅。你需要学习处理编码器产生的脉冲信号,可以使用
Encoder库来简化。
我的建议是:如果你追求完整的游戏体验和练手价值,选择矩阵键盘。如果你希望项目更简洁、更专注于逻辑和显示,选择旋转编码器+确认键。在本篇指南中,我将以“旋转编码器+确认键+OLED屏幕”这个组合作为示例进行详细讲解,因为它兼顾了趣味性、美观度和适中的复杂度。
2.3 电路连接原理图(以旋转编码器+OLED为例)
你需要准备以下元件:
- Arduino Uno R3 x1
- 0.96寸 I2C OLED显示屏 (SSD1306) x1
- 旋转编码器模块(带按键)x1
- 面包板、杜邦线若干
- 蜂鸣器(无源)x1 (可选,用于增加音效)
- 10kΩ电阻 x1 (用于编码器按键的上拉,如果模块已内置则不需要)
接线非常简单,因为主要都使用了I2C总线:
OLED屏幕:
- VCC -> Arduino 5V
- GND -> Arduino GND
- SDA -> Arduino A4 (或标注SDA的引脚)
- SCL -> Arduino A5 (或标注SCL的引脚)
旋转编码器:
- VCC -> Arduino 5V
- GND -> Arduino GND
- SW (按键信号) -> Arduino 数字引脚 2 (外部中断引脚,用于可靠检测按键)
- DT (方向信号B) -> Arduino 数字引脚 3
- CLK (方向信号A) -> Arduino 数字引脚 4
蜂鸣器(可选):
- 正极 -> Arduino 数字引脚 5 (通过一个100Ω电阻限流更安全)
- 负极 -> Arduino GND
为什么编码器的按键要接到引脚2?因为我们可以使用Arduino的外部中断功能来检测按键。在简单的loop()中轮询按键可能会因为主循环处理显示等任务而错过短暂的按下动作。使用中断可以确保每一次按键都能被及时响应,提升游戏体验。引脚2和3在Uno上支持外部中断。
3. 软件架构:状态机与核心逻辑实现
硬件搭好了,接下来就是大脑——程序。编写游戏程序,最忌讳的就是把所有逻辑都堆在loop()函数里。我们需要一个清晰的结构,这里引入“状态机”的概念。
3.1 游戏状态机设计
可以把整个游戏看作是在几个不同的“状态”之间切换:
- STATE_MENU:游戏菜单状态。显示欢迎语或选项(如开始游戏、查看规则)。
- STATE_PLAYING:游戏进行状态。这是核心状态,显示单词、绞刑架、已猜字母,等待玩家输入。
- STATE_WIN:游戏胜利状态。显示庆祝信息。
- STATE_LOSE:游戏失败状态。显示失败信息并公布单词。
在loop()函数中,我们只需要根据一个全局变量gameState的当前值,来执行对应状态下的函数即可。这样逻辑非常清晰,易于调试和扩展。
// 定义游戏状态 enum GameState { STATE_MENU, STATE_PLAYING, STATE_WIN, STATE_LOSE }; GameState gameState = STATE_MENU; void loop() { switch (gameState) { case STATE_MENU: handleMenuState(); break; case STATE_PLAYING: handlePlayingState(); break; case STATE_WIN: handleWinState(); break; case STATE_LOSE: handleLoseState(); break; } // 可能还有一些任何状态都需要处理的公共任务,比如检测编码器旋转 handleEncoderRotation(); }3.2 核心数据结构:如何表示一个单词和猜测进度
这是游戏逻辑的心脏。我们需要在内存中表示三样东西:
- 目标单词(secretWord):一个字符串,比如“ARDUINO”。
- 当前猜测状态(currentGuess):一个与目标单词等长的字符数组,初始为
[‘_‘, ‘_‘, ‘_‘, ‘_‘, ‘_‘, ‘_‘, ‘_‘]。当玩家猜中字母‘A’时,它就变成[‘A‘, ‘_‘, ‘_‘, ‘_‘, ‘_‘, ‘_‘, ‘_‘]。 - 已猜过的错误字母(wrongGuesses):一个列表或字符串,用于存放所有猜错过的字母。
在STATE_PLAYING状态下,玩家的每一次确认操作,程序都需要:
- 获取玩家当前选择的字母。
- 遍历
secretWord,检查该字母是否存在。 - 如果存在,将
currentGuess对应位置上的‘_‘替换为该字母。 - 如果不存在,则将这个字母加入
wrongGuesses列表,并且“错误次数”计数器加一。 - 检查游戏是否结束:如果
currentGuess中已没有‘_‘,则切换到STATE_WIN;如果“错误次数”达到最大值(比如6次,对应绞刑架的6个部分),则切换到STATE_LOSE。
3.3 单词库的存储与随机选择
我们不可能每次只玩一个单词。通常我们会预定义一个单词列表(单词库)。对于Arduino Uno,我们可以直接用一个二维字符数组存储在程序内存(Flash)中,以节省宝贵的SRAM。
// 使用PROGMEM将单词库存放在Flash中 const char wordList[][MAX_WORD_LEN] PROGMEM = { "ARDUINO", "HANGMAN", "COMPUTER", "PROGRAMMING", "EMBEDDED", "DISPLAY", "ROTARY", "ENCODER" }; const int wordCount = 8; // 单词总数在游戏开始时,我们需要随机选择一个单词。这里有一个常见的坑:Arduino的random()函数如果不用randomSeed()初始化,每次上电产生的随机序列是一样的。一个经典的技巧是读取一个未连接的模拟引脚(如A0)的噪声值作为种子。
void selectRandomWord() { // 用模拟引脚噪声初始化随机种子 randomSeed(analogRead(A0)); int index = random(wordCount); // 生成0到wordCount-1的随机数 // 从Flash中读取选中的单词到SRAM中的secretWord数组 strcpy_P(secretWord, (char*)pgm_read_word(&(wordList[index]))); }4. 核心功能模块代码详解
现在,我们把各个模块的代码填充进去。我将以“旋转编码器+OLED”方案为例,展示关键部分的代码。
4.1 驱动OLED绘制游戏界面
首先,我们需要包含必要的库并初始化屏幕。
#include <Wire.h> #include <Adafruit_GFX.h> #include <Adafruit_SSD1306.h> #define SCREEN_WIDTH 128 #define SCREEN_HEIGHT 64 #define OLED_RESET -1 // 重置引脚,如无则填-1 Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET); void setup() { // 初始化OLED,I2C地址通常是0x3C if(!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) { Serial.println(F("SSD1306 allocation failed")); for(;;); // 卡死 } display.clearDisplay(); display.setTextSize(1); display.setTextColor(SSD1306_WHITE); }绘制游戏主界面是一个综合任务。我们需要在屏幕的不同区域显示不同内容。
void drawGameScreen() { display.clearDisplay(); // 1. 在顶部绘制当前猜测状态,如 “A _ _ U I N O” display.setCursor(0, 0); for(int i=0; i<strlen(secretWord); i++){ display.print(currentGuess[i]); display.print(" "); } // 2. 在中间左侧绘制绞刑架和小人 drawGallows(6, 16); // 从坐标(6,16)开始画绞刑架 drawHangman(wrongGuessCount, 6, 16); // 根据错误次数画小人 // 3. 在中间右侧或底部绘制已猜错的字母 display.setCursor(70, 20); display.print("Wrong:"); display.setCursor(70, 30); for(int i=0; i<strlen(wrongGuesses); i++){ display.print(wrongGuesses[i]); display.print(" "); } // 4. 在底部绘制当前玩家选择的字母(一个高亮的字母表) drawLetterSelector(selectedLetterIndex, 0, 50); display.display(); // 将缓冲区内容发送到屏幕显示 }drawGallows和drawHangman函数利用Adafruit_GFX库的画线drawLine、画圆drawCircle等函数来实现。drawLetterSelector函数则需要在屏幕底部绘制一行字母(例如A-Z),并通过一个反色方块或下划线来高亮当前selectedLetterIndex指向的字母。
4.2 处理旋转编码器输入
旋转编码器的工作原理是产生两路相位差90度的方波(A相和B相)。通过判断A相上升沿时B相的电平高低,可以确定旋转方向。我们使用Encoder库来屏蔽这些底层细节。
#include <Encoder.h> Encoder myEncoder(3, 4); // DT接引脚3, CLK接引脚4 long oldPosition = -999; // 保存旧位置 void handleEncoderRotation() { long newPosition = myEncoder.read() / 4; // 每转一圈有4个计数节拍,除以4让变化更平缓 if (newPosition != oldPosition) { oldPosition = newPosition; // 根据newPosition的变化来更新selectedLetterIndex // 例如,newPosition增加,就让selectedLetterIndex在0-25之间循环增加 selectedLetterIndex = (selectedLetterIndex + (newPosition > oldPosition ? 1 : -1) + 26) % 26; // 立即重绘屏幕上的字母选择器 drawLetterSelector(selectedLetterIndex, 0, 50); display.display(); } }编码器的按键我们通过外部中断来检测。
const int encoderButtonPin = 2; volatile bool buttonPressed = false; // volatile关键字在中断中修改的变量必须声明为此类型 void setup() { // ... 其他初始化 pinMode(encoderButtonPin, INPUT_PULLUP); // 启用内部上拉电阻 attachInterrupt(digitalPinToInterrupt(encoderButtonPin), onEncoderButtonPressed, FALLING); // 下降沿触发(按下时从高到低) } // 中断服务函数,要尽可能短 void onEncoderButtonPressed() { buttonPressed = true; } void loop() { // ... 状态机switch if(buttonPressed){ buttonPressed = false; // 清除标志 // 根据当前游戏状态处理按键 if(gameState == STATE_PLAYING){ processGuess(); // 处理玩家的猜测 } else if (gameState == STATE_MENU){ // 开始游戏 gameState = STATE_PLAYING; initNewGame(); } // ... 其他状态的处理 } }4.3 游戏逻辑核心:processGuess()函数
这个函数是游戏规则的具体实现。
void processGuess() { char guessedLetter = 'A' + selectedLetterIndex; // 将索引转换为字母 bool correct = false; // 检查是否已经猜过这个字母(无论是正确还是错误) if(strchr(currentGuess, guessedLetter) != NULL || strchr(wrongGuesses, guessedLetter) != NULL){ // 可以给一个提示,比如让屏幕闪烁一下,或者用蜂鸣器“滴”一声提示重复 playTone(100, 200); // 短促的提示音 return; // 直接返回,不进行后续处理 } // 遍历秘密单词,检查猜测 for(int i=0; i<strlen(secretWord); i++){ if(secretWord[i] == guessedLetter){ currentGuess[i] = guessedLetter; correct = true; } } if(correct){ playTone(523, 300); // 猜对的音效,频率523Hz (C5),300ms // 检查是否获胜 if(strchr(currentGuess, '_') == NULL){ // currentGuess中没有下划线了,说明全猜中了 gameState = STATE_WIN; } } else { // 猜错了 int len = strlen(wrongGuesses); wrongGuesses[len] = guessedLetter; wrongGuesses[len+1] = '\0'; // 字符串结尾 wrongGuessCount++; playTone(262, 500); // 猜错的音效,频率低一些 // 检查是否失败 if(wrongGuessCount >= MAX_WRONG_GUESSES){ // 比如 MAX_WRONG_GUESSES = 6 gameState = STATE_LOSE; } } // 无论对错,都需要重绘游戏界面 drawGameScreen(); }5. 项目优化与扩展思路
一个基础版本完成后,我们可以从多个角度让它变得更好玩、更健壮。
5.1 增加音效与视觉反馈
蜂鸣器不仅能发出单调的声音。通过tone()函数控制频率和时长,我们可以制作简单的音效。例如,猜对时播放一段上升音阶,猜错时播放一个下降的低音,游戏胜利时播放一小段旋律。视觉上,可以在玩家操作时让对应的UI元素短暂闪烁或变色(在OLED上通过快速重绘实现),增强交互感。
5.2 引入单词分类与难度系统
现在的单词库是平的。我们可以将其结构化,例如按类别(动物、水果、科技)或按长度(简单、中等、困难)分组。在STATE_MENU状态,让玩家可以用编码器选择类别和难度。这需要更复杂的菜单系统,但能极大提升游戏的可玩性。
5.3 数据持久化与分数记录
ATmega328P内部有1KB的EEPROM,可以用于保存数据,即使断电也不会丢失。我们可以用它来记录最高分、玩家的总游戏次数、胜率等。每次游戏结束时,计算一个分数(例如,用剩余的错误机会乘以单词长度),如果高于EEPROM中记录的最高分,就更新它并在胜利画面显示“新纪录!”。
5.4 使用更强大的主控(如ESP32)
如果你觉得Uno的性能或内存受限,升级到ESP32是一个爆炸性的选择。ESP32拥有双核处理器、更丰富的内存、Wi-Fi和蓝牙功能。这意味着你可以:
- 从网络获取单词:游戏每次可以从一个在线的API随机获取一个单词,单词库无限大。
- 制作双人对战版:通过蓝牙,两个ESP32设备可以连接,一人出题,一人猜题。
- 更华丽的显示:驱动更大、色彩更丰富的屏幕,实现更流畅的动画。
5.5 常见问题排查(踩坑记录)
OLED屏幕不显示或乱码:首先检查I2C地址。使用一个简单的I2C扫描程序(Arduino IDE示例里有)确认你的屏幕地址是
0x3C还是0x3D。其次,检查接线是否牢固,特别是GND要共地。最后,确保Adafruit_SSD1306和Adafruit_GFX库已正确安装且版本兼容。旋转编码器读数跳变或不准:这是最常见的问题。首先,确保编码器的VCC和GND连接稳定。其次,在编码器的A、B信号线与地之间各接一个0.1uF的电容,可以很好地滤除机械抖动产生的噪声。软件上,使用
Encoder库本身就有较好的防抖处理。如果还不行,尝试调整myEncoder.read() / 4中的除数,或者改用中断方式直接读取A、B相的变化(更复杂但更精确)。程序运行一会儿后卡死:这很可能是内存泄漏或数组越界。检查所有字符数组(如
secretWord,currentGuess,wrongGuesses)是否都分配了足够的大小,并在操作时严格防止越界(例如使用strncpy而不是strcpy)。使用Serial.println(freeMemory())函数(需要额外库)来监控剩余内存,如果内存持续减少,说明有泄漏。蜂鸣器声音太轻或刺耳:无源蜂鸣器需要一定电流驱动。尝试将连接蜂鸣器的数字引脚输出模式设置为
OUTPUT后,再使用tone()。如果声音小,可以尝试减小串联的电阻,或者直接接一个三极管放大电路。声音刺耳可以尝试调整频率,避开人耳敏感的频率段。
这个项目从硬件连接到软件逻辑,完整地走了一遍嵌入式小产品开发的基本流程。它没有用到特别高深的理论,但把Arduino最常用的几项技术都串了起来。当你看到自己亲手做的设备运行起来,朋友家人围在一起猜单词时,那种满足感就是学习硬件编程最大的动力。我建议你在实现基础功能后,一定要尝试至少一项“扩展思路”里的内容,那会让你对如何“打磨”一个项目有更深的理解。硬件项目的乐趣,一半在实现功能,另一半就在解决这些层出不穷的小问题上。
