实战课题

第 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 课那两个——一个都没变,你闭着眼都能接。

三个模块都要接 VCCGND,而且要共地——第 9 课那句话,模块一多更要命。

OLED 上印的是 VDDSCK,不是 VCCSCL——同一个东西,第 19 课讲过。GPIO8/GPIO9 是板子默认的 I²C 脚,能用;如果屏幕偶尔不亮或板子偶尔启动不了,先怀疑这两个脚(想换就换成 GPIO6/GPIO7,代码里 Wire.begin() 改一句)。

这张表本身没有坑——照着插一定能对。坑在别处:在你按下按钮的那一瞬间。(第九节等着你。)

四、动手第一步:屏幕上先出一个 0

跟第 6 课点灯一个道理:先跑通最丑的版本。

C++
#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 课讲的"擦干净、重新画、推上屏"。这一课你要写几十遍,写到手熟。

五、动手第二步:我故意写一段错的给你看

现在加按钮。这一步我要故意写个错的版本——不是让你学错的,是让你亲眼看看"不消抖"长什么样。

C++
void loop() {
  if (digitalRead(4) == LOW) {    // 按着就加
    score = score + 1;
  }
  // 下面照旧画屏幕
}

烧进去,轻轻敲一下按钮,看屏幕。……分数直接飙到几万。

这不是坏了,是我故意的。 你看到的就是第 8 课那个"抖动"的真实后果:你敲了一下,板子在一瞬间读到几十上百次"低电平",每一次都加一分。而且还有第二层问题:程序问的是"你现在按着吗",不是"你刚按下了吗"——你手在上面多停半秒,它又能加上几千分。两件事叠一起,游戏根本没法玩。

还记得第 8 课最后那句吗?"如果你要计数(按一次加一分),必须消抖,否则按一下分数涨几十。"

就是这儿。那句话是给今天埋的。 当时你可能觉得"哪有那么夸张"——现在数字在屏幕上摆着呢。

六、动手第三步:把第 8 课那段代码搬过来

不用重新想,第 8 课写过。搬过来,再加一个"只认按下去那一下"的判断:

C++
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 毫秒——该缩回去了。"

四个新状态

C++
int hole = 0;                        // ★ 地鼠在哪个洞(0、1、2)
bool moleUp = false;                 // ★ 它现在在场上吗
unsigned long moleStart = 0;         // ★ 它什么时候冒的头
unsigned long nextMole = 0;          // ★ 下一次该冒头是什么时候

分数是状态,按钮上次什么样是状态,现在又多了四个。 游戏的每一件事,都是靠这些变量"记"住的。

打地鼠的游戏循环:随机挑一个洞、地鼠冒头开始计时、等你敲、判定加分或算漏,同时程序必须用几个变量记住分数、洞的位置和冒头时间

游戏循环

C++
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 课那个 &&,在这儿真的用上了。

画三个洞

C++
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() 背后是个数学公式,给出一串看着乱、其实是算出来的数。你不告诉它从哪儿开始算,它每次都从同一个起点开始——结果就是每次开机地鼠都从同一个洞开始冒。这不是随机,是"背台词"。

C++
void setup() {
  // ...前面的初始化...
  randomSeed(analogRead(0));     // 用一个悬空的模拟脚当抽签筒
}

为什么悬空的脚能当抽签筒? 它上面什么都没接,会收到周围环境的杂波——你手靠近一点、灯开一下,读数都不一样。每次上电读到的数不同,公式的起点就不同。

前提是 GPIO0 上得空着。 如果你手上正插着第 11 课的光敏(它就在 AOGPIO0),那就换 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我强烈建议你挪线——改代码要改两处(pinModedigitalRead),改一处漏一处,那就是下一个坑。

自测

单选题你在游戏里用 random(3) 挑洞,它可能给出下面哪一组数?
单选题不加 randomSeed() 会怎么样?
单选题"敲中"的条件为什么要写成 now == LOW && moleUp == true
问答题屏幕上地鼠正常冒头、正常缩回去,但敲按钮一点反应都没有。你会怎么用万用表定位?每一步在排除什么?
拓展为什么 random() 这种"假随机"能糊弄过大多数人?真正的随机数是怎么来的?

这个问题课上不讲,留给你自己找答案——**问 AI 是最好的办法**。

提示:搜"伪随机数 种子 梅森旋转"、"硬件随机数 环形振荡器"。想一想:如果公式是公开的、种子也是公开的,别人能不能算出来你下一把地鼠在哪个洞?赌场和密码学为什么在意这件事?

拓展游戏里"同时盯着倒计时、按钮、蜂鸣器"这件事,为什么 delay 干不了?

这个问题课上不讲,留给你自己找答案——**问 AI 是最好的办法**。

提示:搜"Arduino millis 非阻塞 delay 区别"、"millis 溢出 49 天"。想一想:板子睡在 delay 里的时候,地鼠的倒计时谁在看?另外——millis() 这个数一直涨,涨到头会怎样?

本节你要动手做的事

  • [ ] 接好三个模块:按钮→GPIO4,蜂鸣器→GPIO5,OLED→GPIO8/GPIO9三个都要接 VCCGND
  • [ ] 先让屏幕上出现 SCORE 0——最丑的版本先跑通
  • [ ] 故意跑一遍"没消抖"的版本,亲眼看着分数飙到几万,记住那个画面
  • [ ] 搬第 8 课的消抖代码,确认"敲一下就是一下"
  • [ ] 加上地鼠:随机洞、MOLE_LIFEMOLE_GAP,看它冒出来又缩回去
  • [ ] randomSeed,然后注释掉它,连续上电三次对比地鼠从哪个洞开始
  • [ ] 做纸皮机身:开屏幕的窗(描玻璃屏不是描板子)、开按钮的孔、粘斜面、留 USB 缺口
  • [ ] 如果按钮没反应,按第九节那四步量一遍。量出来线插在哪个脚上了吗?

这节课你做出来的东西,跟前面那些不太一样。

前面你做的是"传感器读数"——光有多亮、距离多少,那些东西只是**"反映"世界**。

而这个游戏机,它会"记住"事情。 它记得你敲中几次、地鼠在哪儿、过了多少毫秒——它开始有一个"自己的状态"了。

这就是程序跟电路最大的分界线。 一堆线和几个模块只能做固定的事;加上"状态",它才能有规则、有输赢、有"这一局"和"下一局"。

说句实在的:这是你第一次写超过 60 行的程序,中间肯定会有一段"改了这里那里又不对"的时间——这段人人都要经历,包括我。

而且你别小看刚才那次"分数飙到几万"——大多数人第一次看到那个数字,第一反应是"我抄错代码了吧",然后开始怀疑模块、怀疑板子、怀疑人生。你没有,你回头找了第 8 课那句话,你知道那是抖动。 能把现象和原理对上号,这就是这门课最想教的东西。

下一课我们做第 33 课:桌面温度计——把两个测温度的器件摆在一起,让你亲眼看见"快"和"准"是两回事。 这一次板子判断的不是"你敲中了没有",而是"屋里到底热不热"。