Ciljevi učenja
- Organizovati igru kroz jasno definisana stanja.
- Sačuvati vrijeme početka LOW intervala koji se potvrđuje poslije 30 ms.
- Razlikovati lažni start, važeći odgovor i istek vremena.
- Izostaviti OLED i Serial prenose tokom mjerenog dijela pokušaja.
- Protumačiti rezultat i ograničenja jednostavnog mjerenja reakcije.
Prije početka
Završi kurseve 03, 04 i 11: taster sa debounce obradom, millis() vremensko upravljanje i OLED prikaz.
Referentna postavka
- Ploča u Arduino IDE-u
- ESP32 Dev Module
- Arduino-ESP32
- 3.3.12
- Serial Monitor
- 115200 baud
Ovo je referentni profil, a ne identifikacija ploče sa fotografije. Za ESP32-C3/S2/S3 ili drugačije označenu ploču treba prilagoditi GPIO i provjeriti šemu prije spajanja.
Šta ćemo napraviti
Napravićemo igru reakcije: pritisni taster da pripremiš pokušaj, otpusti ga i sačekaj da LED zasvijetli. Zatim ponovo pritisni taster. OLED prikazuje rezultat u milisekundama i najbolji rezultat od uključivanja. Preuranjeni pritisak i izostanak odgovora završavaju pokušaj bez novog rekorda.
Ovo je nastavna igra, sa ograničenjima očitavanja tastera i mjerača vremena; nije kalibrisan instrument za procjenu sposobnosti ili zdravlja.
Šta ćeš naučiti
- Organizovati interakciju kroz stanja programa.
- Razlikovati početak promjene ulaza od potvrđenog pritiska.
- Sačuvati vrijeme događaja dok traje uklanjanje odskakivanja.
- Obraditi preuranjene i zakašnjele odgovore.
- Izostaviti spore prenose tokom vremenski osjetljivog dijela igre.
Predznanje
Završi kurseve 03, 04 i 11: taster, millis() i OLED. Koristimo klasični ESP32/ESP32-WROOM-32, ne proizvoljnu ploču iz druge ESP32 porodice. Najprije provjeri pojedinačnu LED, taster i OLED ako kombinaciju sastavljaš prvi put.
Potrebna oprema
| Komponenta | Količina | Specifikacija i napomene |
|---|---|---|
| ESP32 razvojna ploča sa klasičnim ESP32-WROOM-32 modulom | 1 | GPIO nazivi moraju odgovarati referentnom profilu; fizički položaj izvoda provjeriti na ploči. |
| USB kabl za podatke i napajanje | 1 | Priključak prilagoditi ploči; komplet na slici navodi Micro-USB. |
| Eksperimentalna ploča | 1 | Komplet navodi 830 kontaktnih tačaka. Provjeriti eventualni prekid naponskih vodova. |
| OLED displej 0,96″ — referentni I2C SSD1306 128×64 | 1 | VCC/GND/SDA/SCL, napajanje 3,3 V, pull-up otpornici na modulu prema 3V3. Kontroler, interfejs i rezolucija sa fotografije nisu potvrđeni. |
| Trenutni taster | 1 | Koristiti par priključaka koji se spaja pritiskom; ista strana četvoronožnog tastera može već biti spojena. |
| Crvena LED dioda | 1 | Anoda A i katoda K; provjeriti polaritet stvarne komponente. |
| Otpornik 220 Ω | 1 | Po jedan otpornik za svaku LED granu; 220 Ω iz kompleta. |
| Spojni kablovi | 9 | M-M ili F-M prema priključcima ploče. Okvirna količina; zavisi od fizičkog rasporeda. |
Za ovu igru nisu potrebni tranzistor, relej ili zvučnik. Referentni OLED je četvoropinski I2C SSD1306, 128 × 64, kompatibilan sa napajanjem i logikom 3,3 V. Fotografija ne potvrđuje kontroler niti raspored izvoda. SH1106 i SPI varijante zahtijevaju drugi spoj i drajver. Adafruit — OLED biblioteka
Šema i redoslijed povezivanja
Šema povezivanja
Sve spoljašnje komponente koriste 3,3 V. Referentni ekran je I2C SSD1306 128×64 sa odgovarajućim pull-up otpornicima; kontroler i adresu provjeri. Dodatni tranzistor, dioda ili relej nisu potrebni.
| Od | Do | Opis veze |
|---|---|---|
ESP32.3V3 | OLED1.VCC | Referentni I2C SSD1306 128×64 na 3,3 V. |
ESP32.GND | OLED1.GND | Zajednička masa. |
ESP32.GPIO21 | OLED1.SDA | I2C podaci. |
ESP32.GPIO22 | OLED1.SCL | I2C takt. |
ESP32.GPIO27 | SW1.CONTACT_A | Taster na ulaz sa INPUT_PULLUP. |
SW1.CONTACT_B | ESP32.GND | Drugi izvod odabranog para kontakata, koji se spaja pritiskom. |
ESP32.GPIO25 | R1.1 | Digitalni izlaz prema serijskom otporniku 220 Ω. |
R1.2 | D1.A | Otpornik prema anodi LED. |
D1.K | ESP32.GND | Katoda LED prema masi. |
- Isključi USB. Poveži OLED VCC na 3V3, GND na GND, SDA na GPIO21 i SCL na GPIO22.
- Poveži GPIO25 preko R1 od 220 Ω na anodu LED, a katodu na GND.
- Izaberi kontakte tastera koji se povezuju pritiskom: jedan na GPIO27, drugi na GND. Dvije nožice koje su već spojene unutar tastera nisu odgovarajući par.
- Provjeri zajedničku masu i da OLED pull-up otpornici idu prema 3,3 V. Zatim priključi USB.
INPUT_PULLUP drži nepritisnut ulaz na HIGH; pritisak ga povezuje sa GND i daje LOW. Oznake GPIO označavaju funkcije izvoda, ne njihov redni položaj na ploči. Espressif — GPIO API
Priprema Arduino okruženja
Koristi Arduino IDE 2.x, esp32 by Espressif Systems 3.3.12, ESP32 Dev Module i Serial Monitor na 115200 baud. Instaliraj biblioteke:
| Biblioteka | Verzija |
|---|---|
| Adafruit SSD1306 | 2.5.17 |
| Adafruit GFX Library | 1.12.6 |
| Adafruit BusIO | 1.17.4 |
Wire je uključen u ESP32 paket. Raspakuj Arduino ZIP, otvori reaction_game.ino iz istoimenog foldera, izaberi port i pokreni Verify pa Upload.
| Biblioteka i izdanje | Verzija | Zavisnosti |
|---|---|---|
| Adafruit SSD1306 | 2.5.17 | Adafruit GFX Library 1.12.6 |
| Adafruit GFX Library | 1.12.6 | Adafruit BusIO 1.17.4 |
| Adafruit BusIO | 1.17.4 | Nema |
Wire je uključen u ESP32 core; ne instalira se zasebno kroz Library Manager.
Kompletan program
#include <Arduino.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
constexpr uint8_t LED_PIN = 25;
constexpr uint8_t BUTTON_PIN = 27;
constexpr uint8_t SDA_PIN = 21;
constexpr uint8_t SCL_PIN = 22;
constexpr uint8_t OLED_ADDRESS = 0x3C;
constexpr uint32_t DEBOUNCE_MS = 30;
constexpr uint32_t REACTION_WINDOW_MS = 3000;
Adafruit_SSD1306 display(128, 64, &Wire, -1, 100000UL, 100000UL);
// Each event is accepted once after 30 ms of stable input.
// If held during reset, a stable release is required before the first press.
struct DebouncedButton {
bool rawHigh = true;
bool stableHigh = true;
bool armed = true;
bool pressed = false;
bool released = false;
uint32_t rawChangedAt = 0;
uint32_t pressStartedAt = 0;
void begin(uint32_t now) {
rawHigh = stableHigh = digitalRead(BUTTON_PIN) == HIGH;
armed = rawHigh;
rawChangedAt = now;
pressed = released = false;
}
void update(uint32_t now) {
pressed = released = false;
const bool readingHigh = digitalRead(BUTTON_PIN) == HIGH;
if (readingHigh != rawHigh) {
rawHigh = readingHigh;
rawChangedAt = now;
}
if (rawHigh != stableHigh && static_cast<uint32_t>(now - rawChangedAt) >= DEBOUNCE_MS) {
stableHigh = rawHigh;
if (stableHigh) {
armed = true;
released = true;
} else if (armed) {
pressed = true;
pressStartedAt = rawChangedAt;
}
}
}
};
DebouncedButton button;
enum GameState { IDLE, RELEASE_TO_START, WAITING, GO, RESULT, FALSE_START, TIMEOUT };
GameState state = IDLE;
bool ready = false;
bool hasBest = false;
uint32_t stateSince = 0;
uint32_t waitDuration = 0;
uint32_t goAt = 0;
uint32_t reactionMs = 0;
uint32_t bestMs = 0;
void finishTrial(GameState outcome, uint32_t score);
void scanI2CBus() {
Serial.println("I2C scan: ACK identifies an address, not the display controller.");
uint8_t found = 0;
for (uint8_t address = 1; address < 127; ++address) {
Wire.beginTransmission(address);
if (Wire.endTransmission() == 0) {
Serial.print("ACK at 0x"); Serial.println(address, HEX);
++found;
}
}
if (found == 0) Serial.println("No I2C device acknowledged.");
}
bool initializeDisplay() {
if (!Wire.begin(SDA_PIN, SCL_PIN)) {
Serial.println("OLED ERROR: I2C initialization failed; reset to retry.");
return false;
}
Wire.setClock(100000);
Wire.setTimeOut(50);
scanI2CBus();
Wire.beginTransmission(OLED_ADDRESS);
if (Wire.endTransmission() != 0) {
Serial.println("OLED ERROR: configured address did not ACK; check wiring/address and reset.");
return false;
}
if (!display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDRESS, false, false)) {
Serial.println("OLED ERROR: buffer initialization failed; reset to retry.");
return false;
}
display.clearDisplay();
display.setTextSize(1);
display.setTextColor(SSD1306_WHITE);
display.setTextWrap(false);
return true;
}
void renderScreen() {
display.clearDisplay();
display.setCursor(0, 0); display.print("Reaction game");
display.setCursor(0, 14);
if (state == IDLE) display.print("Press to start");
else if (state == RELEASE_TO_START) display.print("Release the button");
else if (state == WAITING || state == GO) {
display.print("Watch the LED");
display.setCursor(0, 26); display.print("Press when lit");
} else if (state == RESULT) {
display.print("Reaction: "); display.print(reactionMs); display.print(" ms");
} else if (state == FALSE_START) display.print("FALSE START");
else display.print("TIMEOUT");
display.setCursor(0, 40);
if (hasBest) { display.print("Best: "); display.print(bestMs); display.print(" ms"); }
else display.print("Best: --");
if (state == RESULT || state == FALSE_START || state == TIMEOUT) {
display.setCursor(0, 54); display.print("Press for next round");
}
display.display();
}
void startWaiting() {
randomSeed(micros());
waitDuration = static_cast<uint32_t>(random(2000L, 5001L));
digitalWrite(LED_PIN, LOW);
state = WAITING;
renderScreen(); // Complete this transfer before starting the timing window.
stateSince = millis();
}
void finishTrial(GameState outcome, uint32_t score) {
digitalWrite(LED_PIN, LOW);
state = outcome;
stateSince = millis();
if (state == RESULT) {
reactionMs = score;
if (!hasBest || score < bestMs) { bestMs = score; hasBest = true; }
Serial.print("Reaction_ms="); Serial.print(reactionMs);
Serial.print(" best_ms="); Serial.println(bestMs);
} else Serial.println(state == FALSE_START ? "FALSE START" : "TIMEOUT");
renderScreen();
}
void setup() {
Serial.begin(115200);
pinMode(LED_PIN, OUTPUT);
digitalWrite(LED_PIN, LOW);
pinMode(BUTTON_PIN, INPUT_PULLUP);
button.begin(millis());
ready = initializeDisplay();
if (!ready) return;
stateSince = millis();
renderScreen();
}
void loop() {
if (!ready) return;
const uint32_t now = millis();
button.update(now);
if (state == IDLE || state == RESULT || state == FALSE_START || state == TIMEOUT) {
if (button.pressed) {
state = RELEASE_TO_START;
stateSince = now;
renderScreen();
}
return;
}
if (state == RELEASE_TO_START) {
if (button.released) startWaiting();
return;
}
if (state == WAITING) {
if (button.pressed) { finishTrial(FALSE_START, 0); return; }
if (static_cast<uint32_t>(now - stateSince) >= waitDuration) {
digitalWrite(LED_PIN, HIGH);
goAt = millis();
state = GO;
}
return;
}
// During WAITING and GO, no display transfer or Serial output is performed.
// pressStartedAt is the first observed LOW of the final stable debounce interval.
if (button.pressed) {
const int32_t signedReaction = static_cast<int32_t>(button.pressStartedAt - goAt);
if (signedReaction < 0) finishTrial(FALSE_START, 0);
else if (static_cast<uint32_t>(signedReaction) <= REACTION_WINDOW_MS) {
finishTrial(RESULT, static_cast<uint32_t>(signedReaction));
} else finishTrial(TIMEOUT, 0);
return;
}
// A press beginning at exactly 3000 ms still receives its 30 ms qualification time.
if (static_cast<uint32_t>(now - goAt) >= REACTION_WINDOW_MS + DEBOUNCE_MS) {
finishTrial(TIMEOUT, 0);
}
}
Početna provjera koristi I2C na 100 kHz, skenira adrese i provjerava OLED na OLED_ADDRESS = 0x3C. Ako je stvarna adresa potvrđeno 0x3D, promijeni konstantu. ACK ne potvrđuje SSD1306 kontroler. Pri neuspješnoj pripremi prikaza igra ne počinje, a LED ostaje ugašena.
Stanja i pravila pokušaja
| Stanje | Šta se događa |
|---|---|
IDLE | Novi pritisak traži početak igre. |
RELEASE_TO_START | Čeka se potvrđeno otpuštanje tastera. |
WAITING | LED je ugašena; čeka se izabrani interval. |
GO | LED svijetli i odgovor se vremenski ocjenjuje. |
RESULT | Prikazuju se važeći rezultat i najbolji rezultat. |
FALSE_START | Potvrđen je pritisak započet prije signala. |
TIMEOUT | Nije stigao važeći odgovor unutar prozora. |
Novi pritisak iz završnog stanja vraća igru u čekanje otpuštanja. Zadržan taster nije novi pritisak. Ako je pritisnut pri pokretanju, otpusti ga pa pritisni ponovo.
Čekanje koje počinje poslije otpuštanja
startWaiting() priprema poruku da pratiš LED, a zatim pokreće sat čekanja. Vrijednost micros() pri otpuštanju služi kao početna vrijednost generatora; bira se 2000–5000 ms, uključujući granice. Ovo otežava pogađanje trenutka, ali nije tvrdnja o savršenoj ili kriptografskoj slučajnosti.
Poruka na OLED-u ostaje ista tokom čekanja i reakcije. LED je znak za pritisak. Nakon pokretanja sata čekanja program ne šalje OLED podatke niti Serial izvještaje do završetka pokušaja, da ti prenosi ne odlažu očitavanje tastera. finishTrial() gasi LED, po potrebi ažurira rekord i poziva renderScreen() poslije završetka pokušaja.
Kako se pamti vrijeme pritiska
Taster mora ostati stabilan 30 ms da bi promjena bila prihvaćena. Debouncer istovremeno pamti početak LOW intervala koji kasnije zadovolji taj uslov, u button.pressStartedAt. Ako kontakti odskoče, početak se pomjera na novi LOW interval; ne tvrdi se da je zabilježen prvi fizički dodir. Arduino — odskakivanje tastera
Kada LED zasvijetli, program pamti goAt. Ako je LOW počeo 240 ms kasnije i potvrđen nakon dodatnih 30 ms, rezultat je približno 240 ms, a ne 270 ms. Potvrda određuje da li je pritisak prihvaćen; sačuvano vrijeme određuje rezultat.
Preuranjeni pritisak i krajnji rok
Pritisak započet prije signala ostaje lažni start čak i ako se njegovih 30 ms navrši nakon uključivanja LED. Program poredi početak LOW intervala sa goAt, umjesto da gleda samo stanje u trenutku potvrde. Impuls kraći od vremena potvrđivanja ne predstavlja prihvaćen pritisak.
REACTION_WINDOW_MS je 3000 ms. Program ostavlja dodatnih 30 ms da se kvalifikuje pritisak započet najkasnije na granici od 3000 ms. Rezultat veći od 3000 ms se odbacuje; dodatno vrijeme ne proširuje dozvoljenu reakciju. Potvrđeni događaj obrađuje se prije odluke o isteku. Razlike vremenskih vrijednosti obrađuju i prelazak millis() preko maksimuma.
Prvo pokretanje i eksperiment
Pokreni pet urednih pokušaja, zatim jedan preuranjeni i jedan bez odgovora. Unesi stvarne rezultate u dnevnik, bez unaprijed upisanih „tipičnih“ vremena. Uočeno poboljšanje može nastati vježbom ili pogađanjem; jedan najbolji pokušaj nije potpuna slika reakcije.
| Pokušaj | Postupak | Očekivanje | Tvoje opažanje |
|---|---|---|---|
| 1–5 | Pritisak tek poslije LED | Važeći rezultat; rekord se ažurira ako je manji | Dopuni |
| 6 | Pritisak tokom čekanja | FALSE_START; rekord ostaje | Dopuni |
| 7 | Bez pritiska poslije signala | TIMEOUT; rekord ostaje | Dopuni |
| 8 | Drži taster poslije rezultata | Nema samostalnog ponavljanja | Dopuni |
Rekord bestMs postoji samo tokom trenutnog napajanja/reset ciklusa. Resetovanje briše rezultat; ne koristi se trajna memorija.
Najčešći problemi
| Problem | Provjera |
|---|---|
| Stalno čeka otpuštanje | Provjeri par kontakata, GPIO27 i kratki spoj na masu. |
| LED nikada ne zasvijetli | Provjeri GPIO25, R1, polaritet i prolazak OLED provjere. |
| Ekran djeluje zamrznuto tokom pokušaja | To je namjerno; prati LED, ne promjenu teksta. |
| Dobijaš lažni start nakon paljenja LED | Pritisak je možda počeo prije signala, pa kasnije potvrđen. |
| Kratak dodir nije prihvaćen | Za potvrdu je potreban stabilan LOW tokom 30 ms. |
| Nema OLED odgovora | Provjeri adresu, SDA/SCL, 3,3 V i kontroler. |
Samostalni izazov
Smanji dozvoljeni odgovor na 2000 ms, uz zadržavanje vremena potvrđivanja od 30 ms. Objasni rezultate za LOW ivice na −10, +240, +1995 i +2001 ms u odnosu na LED signal. Pretpostavi da svaka ivica ostaje stabilna dovoljno dugo. Ovo su zadati vremenski primjeri.
Razrađeni odgovori i ograničenja
Postavi REACTION_WINDOW_MS = 2000, zadrži DEBOUNCE_MS = 30 i granicu za završno čekanje računaj iz njihovog zbira. −10 ms je lažni start, čak i ako je potvrđen na +20 ms. +240 ms daje 240 ms. +1995 ms se potvrđuje na +2025 ms i prihvata. +2001 ms je izvan prozora i ne daje važeći rezultat.
Zašto sačuvati sirovo vrijeme? Time ne dodajemo cijeli debounce interval rezultatu. Ipak, petlja ima ograničenu učestalost, millis() konačnu rezoluciju, a kontakti odskakuju. Broj milisekundi je približan rezultat ove implementacije, ne dokaz preciznosti od jedne milisekunde.
Preuzimanja
Sačuvaj dnevnik uz verziju programa. Odvojeno zabilježi stvarnu kompilaciju, provjeru na pločici i rezultate svojih pokušaja.
