Đố toán nhanh trên OLED 4 nút với ESP32: trò chơi luyện tính nhẩm cho học sinh
Làm máy đố toán nhanh bằng ESP32 và màn hình OLED kèm 4 nút: cộng trừ đến 20, bảng nhân 2–9, 4 đáp án cho 4 nút, đếm câu đúng và thời gian, cách tạo đáp án sai gần đúng.
Luyện tính nhẩm sẽ vui hơn nhiều khi biến thành trò chơi có điểm, có đồng hồ. Bài viết hướng dẫn làm máy đố toán nhanh bằng ESP32 và màn hình OLED 0.96 inch kèm 4 nút: màn hình hiện một phép tính và 4 đáp án, người chơi bấm nút có đáp án đúng. Có ba cấp độ: cộng trừ trong phạm vi 20, bảng nhân 2–9 và trộn cả hai; mỗi lượt 10 câu, máy đếm số câu đúng và thời gian.
Ngoài luyện toán, dự án cho học sinh thấy một chương trình "ra đề" như thế nào: số ngẫu nhiên, mảng đáp án, và cách chọn đáp án sai sao cho không đoán bừa được.
1. Chuẩn bị
- Một mạch ESP32 DevKit V1 và dây USB nạp code.
- Một màn hình OLED 0.96 inch SSD1306, chọn loại kèm 4 nút nhấn.
- Tám dây cắm test board loại hai đầu cái.
- Thư viện Adafruit SSD1306 (kèm Adafruit GFX Library và Adafruit BusIO), cài trong Tools > Manage Libraries của Arduino IDE.
2. Nối dây

Cách nối giống hệt trò chơi đo phản xạ và xúc xắc điện tử: I2C dùng D21 (SDA), D22 (SCL); bốn nút K1–K4 nối D32, D33, D25, D26; VCC nối 3V3. Mỗi lô mạch có thể đổi chỗ VCC và GND, nên luôn đọc chữ in cạnh hàng chân trước khi cắm dây nguồn.
3. Chương trình đố toán nhanh
// PYWORLD.VN - ESP32 + OLED 0.96 inch kèm 4 nút: đố toán nhanh, luyện tính nhẩm
// Mỗi câu có 4 đáp án ứng với 4 nút (^ v # *); 10 câu, tính số câu đúng và thời gian
// OLED: SDA = D21, SCL = D22; nút: K1 = D32, K2 = D33, K3 = D25, K4 = D26
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
Adafruit_SSD1306 oled(128, 64, &Wire, -1);
const int SO_NUT = 4;
const int CHAN_NUT[SO_NUT] = {32, 33, 25, 26}; // K1, K2, K3, K4
const char KY_HIEU[SO_NUT] = {'^', 'v', '#', '*'}; // ký hiệu in cạnh K1..K4; sửa theo chương trình kiểm tra nút
const int SO_CAU = 10;
int mucNghi[SO_NUT];
int capDo = 0; // 0: cộng trừ trong 20; 1: bảng nhân; 2: trộn cả hai
// Trả về số thứ tự nút đang bấm (0–3), hoặc -1 nếu không bấm nút nào
int nutDangBam() {
for (int i = 0; i < SO_NUT; i++) {
if (digitalRead(CHAN_NUT[i]) != mucNghi[i]) return i;
}
return -1;
}
// Chờ người chơi bấm một nút rồi nhả ra; trả về số thứ tự nút
int choBamNut() {
int nut;
while ((nut = nutDangBam()) == -1) delay(5);
while (nutDangBam() != -1) delay(10);
delay(30); // chờ hết dội phím
return nut;
}
// Tạo một câu hỏi: ghi đề vào de[], ghi "bước" để tạo đáp án nhiễu, trả về đáp án đúng
int taoCauHoi(char* de, int doDai, int& buoc) {
int loai = (capDo == 0) ? random(2) : (capDo == 1 ? 2 : random(3));
int a, b;
if (loai == 0) { // phép cộng, tổng không quá 20
a = random(1, 11); b = random(1, 11);
snprintf(de, doDai, "%d + %d", a, b);
buoc = 1;
return a + b;
}
if (loai == 1) { // phép trừ, kết quả không âm
a = random(5, 21); b = random(1, a + 1);
snprintf(de, doDai, "%d - %d", a, b);
buoc = 1;
return a - b;
}
a = random(2, 10); b = random(2, 10); // bảng nhân 2–9
snprintf(de, doDai, "%d x %d", a, b);
buoc = a; // nhầm sang hàng bên cạnh của bảng nhân: lệch đúng a
return a * b;
}
// Xếp 4 đáp án: 1 đúng, 3 sai gần đúng (không âm, không trùng); trả về vị trí đáp án đúng
int xepDapAn(int dung, int buoc, int dapAn[]) {
int ungVien[8] = {dung - 1, dung + 1, dung - 2, dung + 2, dung - buoc, dung + buoc, dung + 10, dung - 10};
for (int i = 7; i > 0; i--) { // trộn thứ tự các ứng viên
int j = random(i + 1);
int t = ungVien[i]; ungVien[i] = ungVien[j]; ungVien[j] = t;
}
int viTriDung = random(SO_NUT);
for (int k = 0; k < SO_NUT; k++) dapAn[k] = -1;
dapAn[viTriDung] = dung;
int o = 0;
for (int i = 0; i < 8; i++) {
while (o < SO_NUT && dapAn[o] != -1) o++; // tìm ô còn trống
if (o >= SO_NUT) break;
int v = ungVien[i];
bool boQua = (v < 0);
for (int k = 0; k < SO_NUT; k++) if (dapAn[k] == v) boQua = true;
if (!boQua) dapAn[o] = v;
}
return viTriDung;
}
void manHinhCau(int cau, int diem, const char* de, int dapAn[]) {
char dong[24];
oled.clearDisplay();
oled.setTextSize(1);
oled.setCursor(0, 0);
snprintf(dong, sizeof(dong), "Cau %d/%d Dung %d", cau, SO_CAU, diem);
oled.print(dong);
oled.setTextSize(2);
oled.setCursor(0, 12);
snprintf(dong, sizeof(dong), "%s=?", de);
oled.print(dong);
for (int k = 0; k < SO_NUT; k++) {
oled.setCursor((k % 2) * 64, 32 + (k / 2) * 16);
snprintf(dong, sizeof(dong), "%c %d", KY_HIEU[k], dapAn[k]);
oled.print(dong);
}
oled.display();
}
void hienHaiDong(const char* dong1, const char* dong2) {
oled.clearDisplay();
oled.setTextSize(2);
oled.setCursor(0, 8);
oled.print(dong1);
oled.setCursor(0, 36);
oled.print(dong2);
oled.display();
}
void setup() {
Serial.begin(115200);
oled.begin(SSD1306_SWITCHCAPVCC, 0x3C);
oled.setTextColor(SSD1306_WHITE);
for (int i = 0; i < SO_NUT; i++) pinMode(CHAN_NUT[i], INPUT_PULLUP);
delay(50);
for (int i = 0; i < SO_NUT; i++) mucNghi[i] = digitalRead(CHAN_NUT[i]);
}
void loop() {
// Chọn cấp độ bằng 3 nút đầu
char dong[24];
oled.clearDisplay();
oled.setTextSize(1);
oled.setCursor(0, 0);
oled.print("Do toan nhanh");
const char* TEN_CAP[3] = {"Cong tru den 20", "Bang nhan 2-9", "Tron ca hai"};
for (int k = 0; k < 3; k++) {
oled.setCursor(0, 16 + k * 12);
snprintf(dong, sizeof(dong), "%c %s", KY_HIEU[k], TEN_CAP[k]);
oled.print(dong);
}
oled.display();
int chon;
while ((chon = choBamNut()) > 2) {} // nút thứ tư không dùng ở đây
capDo = chon;
int diem = 0;
unsigned long batDau = millis();
for (int cau = 1; cau <= SO_CAU; cau++) {
char de[16];
int buoc;
int dung = taoCauHoi(de, sizeof(de), buoc);
int dapAn[SO_NUT];
int viTriDung = xepDapAn(dung, buoc, dapAn);
manHinhCau(cau, diem, de, dapAn);
int nut = choBamNut();
if (nut == viTriDung) {
diem++;
hienHaiDong("Dung roi!", "");
} else {
snprintf(dong, sizeof(dong), "%s=%d", de, dung);
hienHaiDong("Chua dung", dong);
}
Serial.printf("Cau %d: %s = %d, chon %d -> %s\n", cau, de, dung, dapAn[nut], nut == viTriDung ? "dung" : "sai");
delay(nut == viTriDung ? 600 : 1500);
}
unsigned long giay = (millis() - batDau) / 1000;
snprintf(dong, sizeof(dong), "%d/%d cau", diem, SO_CAU);
char dong2[24];
snprintf(dong2, sizeof(dong2), "%lu giay", giay);
hienHaiDong(dong, dong2);
Serial.printf("Ket qua: %d/%d cau dung trong %lu giay\n", diem, SO_CAU, giay);
delay(1000);
choBamNut(); // bấm nút bất kỳ để chơi lại
}
Trước khi nạp, sửa mảng KY_HIEU cho đúng mạch của mình: phần tử thứ nhất là ký hiệu in cạnh nút K1, thứ hai cạnh K2… Cách tìm nút nào là K1 có trong bài trò chơi đo phản xạ (chương trình kiểm tra 4 nút).
4. Trò chơi trên màn hình

- Màn hình đầu tiên cho chọn cấp độ bằng ba nút đầu.
- Mỗi câu hiện ở cỡ chữ lớn, bốn đáp án xếp thành hai hàng, mỗi đáp án có ký hiệu của một nút.
- Bấm đúng: "Dung roi!" trong 0,6 giây rồi sang câu mới. Bấm sai: hiện lại phép tính kèm đáp án đúng trong 1,5 giây để trẻ kịp đọc.
- Hết 10 câu, màn hình hiện số câu đúng và số giây đã dùng. Bấm nút bất kỳ để chơi lại.
5. Chương trình tạo đáp án sai như thế nào?

- Hàm
taoCauHoi()chọn loại phép tính theo cấp độ, chọn hai số ngẫu nhiên và trả về đáp án đúng. Phép trừ luôn chọn số bị trừ lớn hơn số trừ để kết quả không âm. - Hàm
xepDapAn()lập 8 ứng viên quanh đáp án đúng: lệch 1, lệch 2, lệch 10, và với phép nhân thì lệch đúng một hàng của bảng nhân (7 x 7, 7 x 9). Đây là những chỗ trẻ hay nhầm nhất, nên đáp án sai trông "rất giống thật". - Thuật toán trộn dùng ở đây là đổi chỗ ngẫu nhiên từ cuối mảng lên đầu, một cách trộn đều được dùng rất nhiều trong lập trình trò chơi.
6. Thử thách mở rộng
- Thêm phép chia: chọn a, b ngẫu nhiên rồi hỏi "a x b : b", để kết quả luôn là số nguyên.
- Đếm ngược: mỗi câu chỉ có 5 giây; hết giờ tính là sai. Gợi ý: dùng
millis()như trò chơi đo phản xạ. - Ghi nhớ câu sai: lưu các câu trả lời sai vào mảng, cuối lượt hỏi lại những câu đó.
- Kỷ lục: lưu thời gian tốt nhất bằng thư viện Preferences để tắt nguồn vẫn còn.
7. Gợi ý cho phụ huynh và giáo viên
- Theo lớp: lớp 1–2 chơi cấp cộng trừ đến 20; lớp 3 trở lên chơi bảng nhân; trộn cả hai để ôn tập.
- So với chính mình: khuyến khích trẻ phá kỷ lục cá nhân về số câu đúng và thời gian, không xếp hạng cả lớp.
- Đổi vai: học sinh lớn đọc chương trình và sửa cho máy ra đề theo ý mình (chỉ bảng nhân 7, chỉ phép trừ có nhớ…) để em nhỏ luyện tập.
- Thời lượng: 1–2 buổi 45 phút: nối dây, nạp chương trình, chơi; buổi sau đọc hàm
xepDapAn()và làm một thử thách.
Xem thêm