实战课题
第 32 课:打地鼠游戏机
第一个"游戏"——板子得学会记住分数、记住时间,还得会抽签
我是吴老师。从这节课起,你做的不是"实验",是"游戏"。
这两个词差在哪儿?我给你说个具体的。
实验,你希望它每次结果都一样。 光敏遮住就是那个数,超声波量桌子就是那个距离——一样,才说明你没做错。
游戏,你希望它每次都不一样。 地鼠这一下从左边冒,下一次从右边冒,你猜不到才对。要是每局都从同一个洞出来,那不叫游戏,那叫"每天八点准时打卡"。
就这一条差别,带出一个全新的东西:板子得开始"记住"事情。 记住你敲中了几次、地鼠从哪个洞冒的、它是几点冒的头——因为地鼠不能一直等你,它冒出来几百毫秒就得缩回去,这是规则。
这个"记住"的本事,正式叫法是状态。今天我们先把它做出来,再回头说它到底是什么。
做完之后你手上是这样的东西:一块纸皮小机身,中间嵌一块屏幕,上面一行分数、下面三个洞。地鼠随机从一个洞里冒出来,你手快敲中它,"嘀"一声加一分;手慢了,它缩回去,算你漏一个。
而且我要提前打个招呼:这节课里有一段是"我故意写错的代码"。 看完那段你就明白,第 8 课那个消抖为什么非学不可。
一、先说清楚:这个课题的三个环节
第 9 课那张图,翻出来再看一眼:
| 环节 | 这个课题里是谁 |
|---|---|
| 传感器(看) | 按钮——你敲下去的那一下 |
| 主控(想) | 地鼠在不在?敲中了没有?超时了没有?加几分? |
| 执行器(做) | OLED 画出分数和地鼠,蜂鸣器"嘀"一声 |
三个都在,一个不少。
但你注意:这次的"判断"变厚了。 前面几课的判断都很单纯——"亮不亮""热不热",一个问题一个答案。这个课题里,主控每一圈要回答四个问题:该冒头了吗?冒太久了吗?你敲了吗?敲中还是敲空?
这就是游戏比实验难的地方,也是它好玩的地方。
二、材料清单
套件里的:ESP32-C3 主控 + 拓展板、按钮模块、OLED 显示屏、有源蜂鸣器、杜邦线若干。
身边要准备的:纸皮(快递盒就行,2~3 毫米最好使,太厚折不动)、美工刀、剪刀、铅笔、直尺、热熔胶枪 + 胶棒、一块废纸皮当垫板(割的时候垫着,别划到桌子)。
两条安全规矩:美工刀永远往外推,手别放在刀的前进路线上,割不动就多划两刀,别使劲压。热熔胶刚挤出来一百多度,别用手按刚粘上的地方,等十秒;溢出来的胶丝用剪刀剪,别用手拽。
三、接线:三个模块,九根线
接之前先断电。
| 模块 | 针脚 | 接到 |
|---|---|---|
| 按钮 | VCC / GND / OUT |
3.3V / GND / GPIO4 |
| 蜂鸣器 | VCC / GND / I/O |
3.3V / GND / GPIO5 |
| OLED | GND / VDD / SCK / SDA |
GND / 3.3V / GPIO9 / GPIO8 |
按钮 GPIO4 是第 8 课那个脚,蜂鸣器 GPIO5 是第 17 课那个脚,OLED 的 GPIO8/GPIO9 是第 19 课那两个——一个都没变,你闭着眼都能接。
三个模块都要接 VCC 和 GND,而且要共地——第 9 课那句话,模块一多更要命。
OLED 上印的是
VDD和SCK,不是VCC和SCL——同一个东西,第 19 课讲过。GPIO8/GPIO9是板子默认的 I²C 脚,能用;如果屏幕偶尔不亮或板子偶尔启动不了,先怀疑这两个脚(想换就换成GPIO6/GPIO7,代码里Wire.begin()改一句)。
这张表本身没有坑——照着插一定能对。坑在别处:在你按下按钮的那一瞬间。(第九节等着你。)
四、动手第一步:屏幕上先出一个 0
跟第 6 课点灯一个道理:先跑通最丑的版本。
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
Adafruit_SSD1306 oled(128, 64, &Wire, -1);
int score = 0; // ★ 这就是"状态"——板子要记住的第一个东西
void setup() {
pinMode(4, INPUT_PULLUP);
Wire.begin(8, 9); // 先 SDA,后 SCL
oled.begin(SSD1306_SWITCHCAPVCC, 0x3C);
oled.clearDisplay();
oled.setTextColor(SSD1306_WHITE);
}
void loop() {
oled.clearDisplay(); // 擦黑板
oled.setTextSize(2);
oled.setCursor(0, 0);
oled.print("SCORE");
oled.setTextSize(3);
oled.setCursor(0, 30);
oled.print(score);
oled.display(); // 推上屏
}现象:屏幕上出现 SCORE 和一个大大的 0。
就这一个 0,意义不小——它是"分数"这个概念第一次出现在你的程序里。 现在它永远是 0,因为还没人能让它变。
clearDisplay()→ 画 →display()这三步,是第 19 课讲的"擦干净、重新画、推上屏"。这一课你要写几十遍,写到手熟。
五、动手第二步:我故意写一段错的给你看
现在加按钮。这一步我要故意写个错的版本——不是让你学错的,是让你亲眼看看"不消抖"长什么样。
void loop() {
if (digitalRead(4) == LOW) { // 按着就加
score = score + 1;
}
// 下面照旧画屏幕
}烧进去,轻轻敲一下按钮,看屏幕。……分数直接飙到几万。
这不是坏了,是我故意的。 你看到的就是第 8 课那个"抖动"的真实后果:你敲了一下,板子在一瞬间读到几十上百次"低电平",每一次都加一分。而且还有第二层问题:程序问的是"你现在按着吗",不是"你刚按下了吗"——你手在上面多停半秒,它又能加上几千分。两件事叠一起,游戏根本没法玩。
还记得第 8 课最后那句吗?"如果你要计数(按一次加一分),必须消抖,否则按一下分数涨几十。"
就是这儿。那句话是给今天埋的。 当时你可能觉得"哪有那么夸张"——现在数字在屏幕上摆着呢。
六、动手第三步:把第 8 课那段代码搬过来
不用重新想,第 8 课写过。搬过来,再加一个"只认按下去那一下"的判断:
int lastBtn = HIGH; // ★ 第二个状态:按钮上次是什么样
void loop() {
int now = digitalRead(4);
if (now != lastBtn) { // 状态变了
delay(20); // 等抖动过去 —— 第 8 课那句
now = digitalRead(4); // 再确认一次
if (now != lastBtn) { // 确认是真的变了
lastBtn = now;
if (now == LOW) { // 只认"按下"这一个方向
score = score + 1;
}
}
}
// 下面照旧画屏幕
}再敲一下试试——加一分。一下就是一下。
消抖的价值,一句话说清:它把"一段乱七八糟的抖动",变成了"一个干净的事件"。
那个
delay(20)会不会把游戏卡住? 会卡 20 毫秒,但地鼠给你 900 毫秒,耽误 20 毫秒还剩 880 毫秒——够反应了,这一步我们不折腾。等以后连 20 毫秒都嫌多的时候,再去看第 23 课怎么用millis()绕开delay。
七、动手第四步:让地鼠冒出来
到这儿"敲一下加一分"已经稳了,但它还不是游戏——没有地鼠、没有时间限制、没有输赢。游戏需要时间,所以这一节要认识一个新朋友。
millis():板子里的挂钟
delay() 是什么感觉?是"睡一觉"——你闭眼睡 800 毫秒,这 800 毫秒里什么都不知道,有人敲门也听不见。但游戏不能让板子睡觉:它要一边盯着地鼠什么时候该缩回去,一边听着你有没有敲。它不能闭眼。
millis() 就是干这个的:它读的是"板子从开机到现在过了多少毫秒",像抬头看一眼墙上的挂钟。 看一眼,不用停下来等——这就是它跟 delay 的根本区别。 有了挂钟就能这么想事:"地鼠 3000 毫秒冒的头,现在 3800 了,它已经待了 800 毫秒——该缩回去了。"
四个新状态
int hole = 0; // ★ 地鼠在哪个洞(0、1、2)
bool moleUp = false; // ★ 它现在在场上吗
unsigned long moleStart = 0; // ★ 它什么时候冒的头
unsigned long nextMole = 0; // ★ 下一次该冒头是什么时候分数是状态,按钮上次什么样是状态,现在又多了四个。 游戏的每一件事,都是靠这些变量"记"住的。
游戏循环
const int MOLE_LIFE = 900; // 地鼠一次待多久(毫秒)——想变难就改小
const int MOLE_GAP = 600; // 两次冒头之间歇多久
int miss = 0; // 漏了几个
void loop() {
// ── ① 该让地鼠冒头了吗 ──
if (moleUp == false && millis() >= nextMole) {
hole = random(3); // 随机挑一个洞:0、1、2
moleUp = true;
moleStart = millis(); // 记下冒头的时刻
}
// ── ② 它待太久了,缩回去 ──
if (moleUp == true && millis() - moleStart > MOLE_LIFE) {
moleUp = false;
miss = miss + 1;
nextMole = millis() + MOLE_GAP;
}
// ── ③ 读按钮(消抖 + 边沿)──
int now = digitalRead(4);
if (now != lastBtn) {
delay(20);
now = digitalRead(4);
if (now != lastBtn) {
lastBtn = now;
if (now == LOW && moleUp == true) { // ★ 敲中的条件:地鼠得在场上
score = score + 1;
moleUp = false;
nextMole = millis() + MOLE_GAP;
digitalWrite(5, HIGH); // 嘀一声
delay(60);
digitalWrite(5, LOW);
}
}
}
// ── ④ 画屏幕 ──
drawGame();
}注意加★那句:now == LOW && moleUp == true。光按下去不算数,得"地鼠在场上"才加分。 这就是游戏的判断——第 9 课那个 &&,在这儿真的用上了。
画三个洞
void drawGame() {
oled.clearDisplay();
oled.setTextSize(1);
oled.setCursor(0, 0);
oled.print("SCORE ");
oled.print(score);
oled.setCursor(80, 0);
oled.print("MISS ");
oled.print(miss);
for (int i = 0; i < 3; i++) {
int x = 8 + i * 40;
oled.drawRect(x, 34, 32, 22, SSD1306_WHITE); // 洞
if (moleUp == true && i == hole) {
oled.fillRect(x + 4, 40, 24, 14, SSD1306_WHITE); // 地鼠冒出来了
}
}
oled.display();
}烧进去。三个空心方框,地鼠在其中一个里面冒出来、缩回去、换个洞再冒。
敲一下试试——敲中了 MISS 不涨、SCORE 涨,蜂鸣器"嘀"。一个游戏,成了。
随机数的两个坑
第一,random(3) 给你 0、1、2,不含 3。 这是编程里极常见的规矩——从 0 开始数,数到"上限减一"(第 19 课调屏幕坐标时见过一次)。想要 1~3 号洞就写 random(3) + 1。
第二,不"播种",每次开机抽的签一模一样。
random() 背后是个数学公式,给出一串看着乱、其实是算出来的数。你不告诉它从哪儿开始算,它每次都从同一个起点开始——结果就是每次开机地鼠都从同一个洞开始冒。这不是随机,是"背台词"。
void setup() {
// ...前面的初始化...
randomSeed(analogRead(0)); // 用一个悬空的模拟脚当抽签筒
}为什么悬空的脚能当抽签筒? 它上面什么都没接,会收到周围环境的杂波——你手靠近一点、灯开一下,读数都不一样。每次上电读到的数不同,公式的起点就不同。
前提是
GPIO0上得空着。 如果你手上正插着第 11 课的光敏(它就在AO→GPIO0),那就换analogRead(1),或者用randomSeed(esp_random())——芯片自带的硬件随机数,更省事。不确定就自己验一遍:把
randomSeed注释掉,连续上电三次,看地鼠是不是都从同一个洞开始。这就叫"先测,再猜"。
八、组装:纸皮机身
电路通了,但它还是"一堆躺在桌上的板子"。我们要把它变成能拿在手里敲的东西。
关键要求:按钮要能敲,机身必须稳。 一敲就跑的机身,游戏体验是零。
要剪的纸皮:底板 14 cm × 10 cm(躺平那块)、面板 14 cm × 9 cm(朝上那块,开 OLED 的窗)、侧板 ×2(直角三角形,直角边 10 cm 和 6 cm,把面板撑成斜面),外加几条 1 cm 宽的纸皮条用来加固、垫脚。
为什么要斜面? 因为你要坐着低头看它。面板平躺你得站起来才看得清,斜着仰起 30 度左右最舒服。
① 开屏幕的窗。 把 OLED 背面朝上放在面板纸皮上,铅笔沿着玻璃屏的边缘描一圈。注意:描的是玻璃屏,不是整块电路板! 玻璃屏和板子边缘之间有一圈黑色的边,那一圈是要挡在纸皮后面的——剪成整块板的尺寸,屏幕会从洞里掉出去。描完垫上废纸皮,用美工刀沿线下刀。
② 粘成斜面。 两个三角形侧板,长直角边(10 cm)贴底板,短直角边(6 cm)竖起来。面板斜着搭在两条斜边上,热熔胶粘住。粘之前先干着摆一下位置——粘上了再想改就得撕纸皮。
③ 开按钮的孔。 按钮圆帽按在面板上(选屏幕下方、你手自然搭上去的位置),描一圈,割一个圆孔——孔刚好让圆帽穿过去,板子留在里面。 从面板背面把按钮塞进去,热熔胶粘在面板内侧。
④ 固定屏幕。 OLED 从面板背面贴上去,玻璃屏正好露在窗口里。热熔胶点在板子四个角上——别往屏幕上挤,胶一旦流到玻璃上,撕都撕不下来。
⑤ 主控、蜂鸣器放外面,背面留个缺口给 USB 线。 塞进机身里更整齐,但以后改线要拆,第一版就放外面;USB 线不从缺口走的话会被纸皮压着,插头被拽松——这是"时好时坏"故障的经典来源。
⑥ 最后在面板空白处写上"打地鼠"。
调难度
装好之后就可以调手感了:更难就把 MOLE_LIFE 从 900 改到 500,更急就把 MOLE_GAP 从 600 改到 300,更慢就两个都往上加。先自己玩十局,再决定改哪个数——手感是调出来的,不是想出来的。
九、排查:按下按钮完全没反应
这是这个课题里我给你留的坑。
做完之后,你会遇到这么个现象:
屏幕上分数稳稳停在 0,地鼠照样冒出来、照样缩回去(说明程序在跑、屏幕是好的、时间逻辑也对)。 但你敲按钮——一点反应都没有。 分数永远不涨,蜂鸣器永远不响。
最气人的是:代码你看了三遍,一个字都没错。因为它确实没错。
别急,按这个顺序查。 这就是第 5 课那套"电 → 信号 → 代码",只不过这次要量两头。
第 0 步:先确认板子还活着。 看屏幕——地鼠在动,说明程序在跑。 这一步排除"程序卡死"。别跳过它,很多人一上来就怀疑代码,折腾半天其实是程序根本没跑起来。
第 1 步:量电。 直流电压档,黑表笔搭板子的 GND,红表笔点按钮模块自己的 VCC 针——应该 3.3V 左右。不是就先查那根线。
第 2 步:量按钮模块自己,看它到底出没出信号。 红表笔点在按钮模块的 OUT 针上(记住:是模块上那根针,不是板子上的孔),黑表笔还是搭 GND。
- 不按的时候:3.3V 左右
- 按住的时候:0V 左右(手指按着别动,眼睛盯着表)
如果这两个数确实变了,你就一次排除了两个可能:按钮模块是好的,信号也确实从它这儿出来了。 这是整条排查路线里最关键的一刀——它把"模块坏了"这个方向彻底切掉了。
第 3 步:量板子那一头。 红表笔点到板子拓展板上标着 4 的那个孔——就是代码里 digitalRead(4) 说的那个脚。
- 不按的时候:3.3V(上拉电阻的功劳,第 8 课讲的)
- 按住的时候:还是 3.3V。没变。
到这儿答案就出来了:模块那头在变,板子这头不变——这两头根本不是同一根线。
第 4 步:通断档确认。先断电。 万用表拨通断档(会"嘀"的那个档)。一根表笔点按钮模块的 OUT 针,另一根点板子上 4 那个孔——不响。 再点旁边 6 那个孔——嘀!响了。
根因
按钮的 OUT 插在了 GPIO6 上,而代码里写的是 4。
这就是全部的问题。 代码没错,接线也没接坏,但两边说的不是同一个脚。 板子老老实实在 GPIO4 上等你按,你的按钮却在往 GPIO6 上发信号——它俩隔着一堵墙互相喊,谁也不理谁。
从现象上你永远猜不到是这个:屏幕照样跑,程序一点毛病没有,唯独你的手失效了。
这个坑是我故意留的,但它也完全是真实情况——拓展板上一排排针挨得紧紧的,插错一格你根本看不出来。
记住一条铁律:引脚号是"代码"和"接线"之间唯一的约定。 以后遇到"没反应",先问自己一句:代码里写的是几?线插的是几?
这个坑有两个修法:把线挪到
GPIO4,或者把代码里的4全改成6。我强烈建议你挪线——改代码要改两处(pinMode和digitalRead),改一处漏一处,那就是下一个坑。
自测
random(3) 挑洞,它可能给出下面哪一组数?答案与解析
random(n) 给的是从 0 到 n−1 的数。这是编程里极常见的规矩:从 0 开始数,数到"上限减一"。想在屏幕上显示 1~3 号洞就写 random(3) + 1。第 19 课调屏幕坐标时也是从 0 开始的——同一个规矩。
randomSeed() 会怎么样?答案与解析
random() 背后是个数学公式,给出一串看着乱、其实是算出来的数。你不告诉它从哪儿开始算(这就是"播种"),它每次上电都从同一个起点算——看着随机,其实每次都一样。 拿一个悬空的模拟脚(analogRead(0))或芯片自带的 esp_random() 当种子,起点就每次都不同。
now == LOW && moleUp == true?答案与解析
光按下去不算敲中——地鼠不在的时候你乱敲,凭什么加分? 所以是两个条件同时成立(第 9 课那个 &&)。只写 now == LOW 的话,就变成"随时敲都能加分",这个游戏也就没有输赢了。
答题要点
答题要点应该包含:① 先看屏幕确认程序在跑,排除"程序卡死";② 直流电压档量按钮模块的 VCC,排除"模块没供电";③ 量按钮模块自己的 OUT 针(不按 3.3V、按住 0V),这一步同时排除了"模块坏了"和"信号没出来";④ 再量板子上代码指定的那个脚——按住时电压不变,说明信号没到板子要读的那个脚上;⑤ 断电后用通断档量按钮 OUT 和板子各个引脚之间通不通,量到某个脚"嘀"了,就找到线实际插在哪儿了。根因通常是"线插的位置"和"代码里的引脚号"不一致——代码和接线各说各话,谁都没错,合起来就是"按了没反应"。
拓展为什么 random() 这种"假随机"能糊弄过大多数人?真正的随机数是怎么来的?
这个问题课上不讲,留给你自己找答案——**问 AI 是最好的办法**。
提示:搜"伪随机数 种子 梅森旋转"、"硬件随机数 环形振荡器"。想一想:如果公式是公开的、种子也是公开的,别人能不能算出来你下一把地鼠在哪个洞?赌场和密码学为什么在意这件事?
拓展游戏里"同时盯着倒计时、按钮、蜂鸣器"这件事,为什么 delay 干不了?
这个问题课上不讲,留给你自己找答案——**问 AI 是最好的办法**。
提示:搜"Arduino millis 非阻塞 delay 区别"、"millis 溢出 49 天"。想一想:板子睡在 delay 里的时候,地鼠的倒计时谁在看?另外——millis() 这个数一直涨,涨到头会怎样?
本节你要动手做的事
- [ ] 接好三个模块:按钮→
GPIO4,蜂鸣器→GPIO5,OLED→GPIO8/GPIO9,三个都要接VCC和GND - [ ] 先让屏幕上出现
SCORE 0——最丑的版本先跑通 - [ ] 故意跑一遍"没消抖"的版本,亲眼看着分数飙到几万,记住那个画面
- [ ] 搬第 8 课的消抖代码,确认"敲一下就是一下"
- [ ] 加上地鼠:随机洞、
MOLE_LIFE、MOLE_GAP,看它冒出来又缩回去 - [ ] 加
randomSeed,然后注释掉它,连续上电三次对比地鼠从哪个洞开始 - [ ] 做纸皮机身:开屏幕的窗(描玻璃屏不是描板子)、开按钮的孔、粘斜面、留 USB 缺口
- [ ] 如果按钮没反应,按第九节那四步量一遍。量出来线插在哪个脚上了吗?
这节课你做出来的东西,跟前面那些不太一样。
前面你做的是"传感器读数"——光有多亮、距离多少,那些东西只是**"反映"世界**。
而这个游戏机,它会"记住"事情。 它记得你敲中几次、地鼠在哪儿、过了多少毫秒——它开始有一个"自己的状态"了。
这就是程序跟电路最大的分界线。 一堆线和几个模块只能做固定的事;加上"状态",它才能有规则、有输赢、有"这一局"和"下一局"。
说句实在的:这是你第一次写超过 60 行的程序,中间肯定会有一段"改了这里那里又不对"的时间——这段人人都要经历,包括我。
而且你别小看刚才那次"分数飙到几万"——大多数人第一次看到那个数字,第一反应是"我抄错代码了吧",然后开始怀疑模块、怀疑板子、怀疑人生。你没有,你回头找了第 8 课那句话,你知道那是抖动。 能把现象和原理对上号,这就是这门课最想教的东西。
下一课我们做第 33 课:桌面温度计——把两个测温度的器件摆在一起,让你亲眼看见"快"和"准"是两回事。 这一次板子判断的不是"你敲中了没有",而是"屋里到底热不热"。