Saper na Arduino

Od czasu do czasu odwiedza mnie moja ciocia ze swoim 11 letnim synem. Chcąc nieco zainteresować kuzyna informatyką i elektroniką postanowiłem pokazać mu, że aby zrobić coś fajnego nie trzeba wcale poświęcać na to ogromnych środków. Wystarczy trochę chęci oraz kilka rzeczy, które można znaleźć w Starter-Kicie Nettigo. Przy nim złożyłem oraz napisałem kod do gry ciepło-zimno (albo Saper). Młody zadawał mnóstwo pytań o działanie całego tego urządzenia. Widać że chyba złapał bakcyla :) W tym poradniku przedstawię Wam krok po kroku jak zrobić taką grę samemu.

Lista potrzebnych części

Schemat podłączenia

Nim przystąpimy do programowania układu podłączmy wszystkie niezbędne elementy do Arduino według zaprezentowanego poniżej schematu. Podobnie jak w projekcie #SK01, aby ograniczyć ilość przewodów oraz zużyć jak najmniej portów cyfrowych Arduino podłączyliśmy wszystko za pomocą magistrali I2C :)

Zasady gry

  1. Wciskamy dowolny przycisk na klawiaturze, aby rozpocząć grę
  2. Arduino losuje w tajemnicy przed nami któryś z przycisków
  3. Zgadujemy który przycisk został wylosowany
  4. Arduino podpowiada nam ciepło/zimno, a także dodatkowe wskazówki (np. czy przycisk jest w tym samym wierszu)
  5. Odgadujemy poprawny przycisk
  6. Arduino wyświetla po ilu próbach udało nam się odgadnąć właściwy przycisk
  7. Możemy zagrać jeszcze raz

Uruchomienie

Pierwszym krokiem, który musimy zrobić jest zaopatrzenie się w biblioteki o nazwie LiquidCrystal_PCF8574 oraz PCF8574 do obsługi wyświetlacza i ekspandera. Pierwszą z nich możemy pobrać przez Menadżer bibliotek, natomiast kolejną możemy pobrać stąd.

Po zainstalowaniu biblioteki PCF8574 ściągamy kod, wgrywamy w Arduino i możemy zaczynać grę!

Kod programu

Omówmy szkic:  na samym początku załączamy biblioteki oraz definiujemy adresy I2C ekranu LCD oraz macierzy przycisków:

#include <Wire.h>
#include <LiquidCrystal_PCF8574.h>
#include "PCF8574.h"

#define LCD_ADDR  0x27  // Adres I2C wyświetlacza LCD
#define BTN_ADDR  0x20  // Adres I2C macierzy przycisków

O tym jak wyszukać adresy urządzeń I2C pisałem już w #SK01 w sekcji Skąd wziąć adres urządzenia?.

W następnej kolejności tworzymy stałe z informacjami o wierszach i kolumnach klawiatury oraz podpowiedziami

const int ROWS[] = {4, 5, 6, 7};  // Do których pinów expandera podłączone są wiersze macierzy?
const int COLS[] = {3, 2, 1, 0};  // Do których pinów expandera podłączone są kolumny macierzy?
const int NUM_ROWS = sizeof(ROWS)/sizeof(int);  // Liczba wierszy (obliczana automatycznie)
const int NUM_COLS = sizeof(COLS)/sizeof(int);  // Liczba kolumn (obliczana automatycznie)
const String HINTS[] = {"Goraco", "Cieplo", "Zimno"}; // Podpowiedzi wyświetlane na ekranie

Teraz przyszedł czas na globalne zmienne lcd oraz buttons, które pomogą nam komunikować się z ekranem i przyciskami.

LiquidCrystal_PCF8574 lcd(LCD_ADDR);
PCF8574 buttons;

Jeszcze kilka pomocniczych zmiennych typów prostych typu liczba lub wartośc logiczna:

int lastKeyReading = 0;
int correctKey = 0;
int attemptsCount = 0;
bool isRandomSeedSet = false;

O tym do czego służą dowiemy się później – w trakcie pisania kodu, gdzie będą używane.

Teraz jedna z ważniejszych (oraz nowych) rzeczy. Musimy stworzyć strukturę, która będzie reprezentowała nam relację pomiędzy 2 przyciskami (tutaj tym aktualnie wciśniętym oraz tym wylosowanym przez Arduino).

typedef struct {
  bool sameDiagonal;
  bool sameColumn;
  bool sameRow;
  int distance;
} KeyRelationship;

Czym jest struktura w C++? Najprościej możnaby powiedzieć, że tworzymy nowy typ zmiennej, który będzie miał określone przez nas właściwości (tutaj sameDiagonal, sameColumn itd.). Właściwości te będziemy mogli odczytywać oraz modyfikować. Zobaczycie jak to wygląda w praktyce kilkanaście linijek niżej :)

Funkcja setup()

Rozpoczynając pracę kontroler musi wykonać kilka podstawowych czyności (np. zainicjować ekran i macierz przycisków, wyświetlić ekran powitalny). W naszym przypadku kod odpowiedzialny za tę wstępną inicjalizację wygląda tak:

void setup() {
  lcd.begin(16, 2);
  lcd.setBacklight(true);

  buttons.begin(BTN_ADDR);
  for(int x = 0; x < NUM_ROWS; x++) {
    buttons.pinMode(ROWS[x], OUTPUT);
    buttons.digitalWrite(ROWS[x], LOW);
  }

  for(int x = 0; x < NUM_COLS; x++) {
    buttons.pinMode(COLS[x], INPUT_PULLUP);
  }

  lcd.println("Wcisnij przycisk,");
  lcd.setCursor(0, 1);
  lcd.print("aby rozpoczac :)");
}

Obsługa klawiatury

Ekran LCD już obsługiwaliśmy w poprzednich poradnikach, więc w tym przypadku skupię się na nowości, czyli dwóch pętlach inicjalizujących przyciski w funkcji setup().

Pierwsza z nich ustawia wszystkie wiersze jako wyjścia ze stanem niskim, natomiast druga wszystkie kolumny jako wejścia z wewnętrznym rezystorem podciągającym do +5V. Dlaczego tak? Należy zauważyć, że w ekspanderze mamy 8 pinów, a przycisków jest razem 16. Od razu widać, że nie jesteśmy w stanie obsłużyć wszystkich osobno, dlatego zastosujemy tutaj sprytny trik:

  1. Będziemy skanowali wszystkie kolumny po kolei, za każdym razem ustawiając w jednym wierszu stan WYSOKI, a w innych NISKI.
  2. Odczytując stan każdej kolumny dowiemy się, który przycisk jest wciśnięty (tylko jeden z nich będzie miał stan WYSOKI)
  3. Znając numer wiersza oraz numer kolumny wiemy też, który przycisk jest wciśnięty :)

Funkcja implementująca powyższy algorytm wygląda w następujący sposób:

int getKey() {
  for(int x = 0; x < NUM_ROWS; x++) {
    for(int row = 0; row < NUM_ROWS; row++) buttons.digitalWrite(ROWS[row], x != row);
    for(int y = 0; y < NUM_COLS; y++) {
      if(!buttons.digitalRead(COLS[y])) return x * NUM_ROWS + y + 1;
    }
  }
  return 0;
}

Zwraca ona 0 w przypadku nie wciśnięcia żadnego przycisku lub liczbę z zakresu 1-16 odpowiadającą danemu przyciskowi.

Sprawdzanie wzajemnej relacji między dwoma przyciskami

W zasadzie najważniejszy element logiki gry zawiera się w tej funkcji. Pokażę Wam tutaj jak w prosty sposób sprawdzić, czy podane 2 przyciski są na wspólnej przekątnej, w tym samym wierszu, w tej samej kolumnie oraz obliczyć ich odległość od siebie (przyda się do podpowiedzi ciepło/zimno)

KeyRelationship calculateRelationship(int key1, int key2) {
  KeyRelationship relationship;

  int row1 = (key1 - 1) / NUM_COLS;
  int row2 = (key2 - 1) / NUM_COLS;
  int column1 = ((key1 - 1) % NUM_COLS);
  int column2 = ((key2 - 1) % NUM_COLS);

  relationship.sameColumn = column1 == column2;
  relationship.sameRow = row1 == row2;
  relationship.sameDiagonal = abs(row1 - row2) == abs(column1 - column2);
  relationship.distance = round(sqrt(pow(column1 - column2, 2) + pow(row1 - row2, 2)));
  
  return relationship;
}

Na wejściu funkcji przyjmujemy 2 numery przycisków. Musimy rozszyfrować z nich jaki mają wiersz oraz kolumnę. Robimy to przez odwrócenie czynności return x * NUM_ROWS + y + 1; użytej w funkcji getKey(). Teraz już z górki: przyciski są w tej samej kolumnie, jeżeli column1 jest równy column2, analogicznie jeżeli chodzi o sprawdzenie tego samego wiersza. Jak sprawdzić, czy przyciski leżą na tej samej przekątnej? Wystarczy sprawdzić, czy odległość ich wierszy jest równa odległości ich kolumn :) Odległość przycisków od siebie obliczymy na podstawie twierdzenia Pitagorasa. Znamy w końcu wysokość, oraz długość podstawy takiego trójkąta prostokątnego, więc łatwo możemy obliczyć przekątną, która jest odległością od siebie jego dwóch wierzchołków.

Oczywiście zapisujemy te dane do stworzonej wcześniej zmiennej typu KeyRelationship i zwracamy. Prawda że struktury są fajne? Dzięki nim możemy zwrócić kilka powiązanych wartości z jednej funkcji.

Element losowości

Aby nasza gra miała sens musimy sprawić, aby losowane przez nią liczby były losowe. Posłużą nam do tego dwie funkcje wbudowane w Arduino:

  • random(min, max)
  • randomSeed(seed)

Pierwsza z nich zwróci nam losową liczbę z zakresu od min do max zamkniętego z lewej strony (czyli podając parametry kolejno 5, 10 mamy szanse na otrzymanie liczb 5, 6, 7, 8, 9). Druga funkcja odpowiada za tak zwane ziarno, czyli liczbę, która będzie podstawą do generowania kolejnych liczb. Ustawiając za każdym razem to samo ziarno otrzymamy takie same wyniki losowania, dlatego też musimy zadbać, aby ziarno było w miarę możliwości unikalne. Jedni ustawiają tam wartość odczytaną z niepodłączonego nigdzie wejścia analogowego (np. randomSeed(analogRead(A0));), jednak skoro mamy do dyspozycji przyciski to możemy ustalić ziarno na podstawie czasu (w milisekundach) wciśnięcia przycisku po raz pierwszy. Istnieje bardzo mała szansa, że użytkownik dwa razy po starcie urządzenia wciśnie przycisk po raz pierwszy w dokładnie tej samej milisekundzie.

Funkcja, która zwróci nam losową liczbę odpowiadającą jakiemuś przyciskowi będzie wyglądała tak:

int generateRandomKey() {
  return random(1, NUM_ROWS * NUM_COLS + 1);
}

Główna pętla gry

Na samym początku każdego wykonania tej pętli musimy sprawdzić jaki przycisk jest aktualnie wciśnięty

int currentKeyReading = getKey();

Teraz sprawdzamy, czy wciśnięty przycisk nie jest 0 (czyli właściwie brakiem wciśnięcia przycisku) oraz czy jest różny od poprzedniego odczytu (chcemy, aby czynności wykonywały się jednorazowo po wciśnięciu przycisku, a nie cały czas jak go trzymamy). Dlatego piszemy taki oro warunek, który będzie obejmował prawie całą pętlę gry:

if(currentKeyReading != 0 && currentKeyReading != lastKeyReading) {
    ...
}
lastKeyReading = currentKeyReading;

Jak widać na końcu pętli aktualizujemy zmienną lastKeyReading niezależnie od tego, jaki przycisk został wciśnięty.

Kod który będziemy pisali teraz należy wstawiać w miejsce trzech kropek w powyższym listingu.

Tak jak wcześniej mówiłem po pierwszym wciśnięciu jakiegokolwiek przycisku należy ustalić ziarno (tylko jednorazowo):

if(!isRandomSeedSet) {
    randomSeed(millis());
    isRandomSeedSet = true;
}

Wiemy też, że każde wciśnięcie przycisku spowoduje zwiększenie liczby podjętych prób oraz wyświetlenie nowej wiadomości na ekranie:

attemptsCount++;
lcd.clear();

Na tym etapie pozostało nam tylko sprawdzenie trzech możliwych wariantów i wykonanie odpowiednich akcji w przypadku każdego z nich. Te warianty to:

  • Gra się jeszcze nie rozpoczęła (wciśnięcie przycisku rozpoczyna grę)
  • Gra się rozpoczęła i gracz trafił w dobry przycisk
  • Gra się rozpoczęła i gracz spudłował

Przypadek pierwszy

if(correctKey == 0) {
    correctKey = generateRandomKey();
    attemptsCount = 0;
    lcd.print("Odgadnij");
    lcd.setCursor(0, 1);
    lcd.print("przycisk :)");
}

correctKey jest równy 0, więc losujemy właściwy przycisk, resetujemy ilość podejść i informujemy użytkownika, aby rozpoczął odgadywanie.

Przypadek drugi

else if(currentKeyReading == correctKey) {
    lcd.print(String("Bingo! ") + attemptsCount + " prob");
    lcd.setCursor(0, 1);
    lcd.print("Nowa gra?");
    correctKey = 0;
}

Aktualnie odczytany przycisk jest równy wylosowanemu przyciskowi, więc informujemy o zakończeniu gry, ilości prób i ustawiamy correctKey na 0, aby przy kolejnym wciśnięciu przycisku wywołał się warunek z przypadku pierwszego.

Przypadek trzeci

else {
    KeyRelationship relationship = calculateRelationship(currentKeyReading, correctKey);
    
    if(relationship.distance - 1 >= sizeof(HINTS) / sizeof(String)) {
      lcd.print(HINTS[sizeof(HINTS) / sizeof(String) - 1]);
    } else {
      lcd.print(HINTS[relationship.distance - 1]);
    }

    lcd.setCursor(0, 1);
    if(relationship.sameDiagonal) {
      lcd.print("Na przekatnej");
    }else if(relationship.sameColumn) {
      lcd.print("W tej kolumnie");
    }else if(relationship.sameRow) {
      lcd.print("W tym wierszu");
    }
  }
}

Tutaj mamy najwięcej kodu, jednak nie ma się czego bać. Nie ma w nim nic skomplikowanego :) Wiemy, że użytkownik nie trafił w dobry przycisk, więc w pierwszej linijce sprawdzamy jaka jest relacja pomiędzy wybranym przez użytkownika przyciskiem, a tym wylosowanym przez Arduino.

Następnie wyświetlamy na ekranie odpowiednią podpowiedź w zależności od odległości między przyciskami uważając przy tym, aby nie wyjść poza obszar tablicy HINTS.

Kolejną informacją, którą przekażemy użytkownikowi będzie to, czy przycisk jest w tej samej kolumnie, wierszu, czy też na przekątnej. W tym momencie żaden z tych przypadków nie może wystąpić w parze z drugim, więc robimy poprostu drabinkę if .. else if .. else if.

To koniec

Teraz możemy skompilować szkic, który stworzyliśmy i spróbować swoich sił w walce z komputerem. Właśnie stworzyliśmy swoją własną grę.