Pong na wyświetlaczu SSD1306

Niedawno na blogu ukazał się wpis opisujący jak podłączyć oraz obsługiwać wyświetlacz z kontrolerem SSD1306 do Arduino. Korzystając z okazji, że akurat miałem pod ręką bardzo podobny wyświetlacz postanowiłem zrobić prostą implementację gry Pong. Mój wyświetlacz różni się tym, że jest jednokolorowy i wysoki na 32 pixele, a nie 64. Nie stanowi to jednak żadnej przeszkody, gdyż napisany przeze mnie kod będzie można łatwo przenosić pomiędzy różnymi rozmiarami ekranów :)

Potrzebne części

Podłączenie

Ekran do Arduino podłączamy zgodnie z instrukcją w tym artykule.

Piny wejściowe dla przycisków ustawimy w tryb INPUT_PULLUP dlatego też przyciski wystarczy z jednej storny podłączyć wszystkie do masy, a z drugiej strony każdy przycisk do innego wejścia cyfrowego. Ja wybrałem piny analogowe, gdyż akurat nie miałem długich kabelków, a te były najbliżej :)

Omówienie kodu

Kiedy już wszystko będzie połączone możemy omówić po kolei kod programu (cały kod do pobrania tutaj).

Jak widać mamy tu podział na 2 pliki:

  • Pong.ino
  • Game.cpp

Zdecydowałem się na taki krok, aby oddzielić od siebie klasę gry od kodu funkcji loop oraz setup. Przyjrzjmy się najpeirw klasie Game:

    int oledWidth, oledHeight;
    int ballPos[2];
    int ballDir[2];
    
    int batPos[2][2];
    int batHeight;
    int batButtons[2][2];

    Adafruit_SSD1306 * display;

Posiada ona prywatne pola opisujące całą planszę do gry. Mamy tutaj długość oraz szerokość, pozycję piłeczki (x, y), kierunek ruchu piłeczki (góra/dół oraz prawo/lewo), położenie x,y obu paletek, ich wysokość, przyciski przypisane do sterowania nimi oraz wskaźnik na obiekt klasy obsługującej ekran.

    void setInitialPos() {
      ballPos[0] = oledWidth / 2;
      ballPos[1] = oledHeight / 2;
      ballDir[0] = rand() % 2;
      ballDir[1] = rand() % 2;
      
      batPos[0][0] = 0;
      batPos[1][0] = oledWidth - 1;

      batPos[0][1] = batPos[1][1] = (oledHeight / 2) - (batHeight / 2);
    }

Znajdziemy tutaj też prywatną metodę, która jest odpowiedzialna za ustawienia paletek oraz piłeczki w odpowiednich początkowych pozycjach oraz wylosowanie kierunku ruchu piłki.

Spójrzmy na 3 najważniejsze funkcje całej klasy tj. calcBats, calcBall oraz draw:

    void calcBall() {
      for(int x = 0; x < 2; x++) {
        if(ballDir[x]) {
          ballPos[x]++;
        } else {
          ballPos[x]--;
        }
      }

      if(ballPos[0] <= 0) {
        if(checkForFail(0)) {
          startNewGame();
          return;
        }
        ballDir[0] = !ballDir[0];
      } else if(ballPos[0] >= oledWidth - 1) {
        if(checkForFail(1)) {
          startNewGame();
          return;
        }
        ballDir[0] = !ballDir[0];
      }


      if(ballPos[1] <= 0 || ballPos[1] >= oledHeight - 1) {
          ballDir[1] = !ballDir[1];
      }
    }

Funkcja calcBall() oblicza pozycję piłeczki oraz sprawdza, czy została odbita przez paletkę. W pierwszej pętli na początku funkcji przesuwamy współrzędne w odpowiednim kierunku. Następnie napotykamy na blok instrukcji warunkowych, w któych sprawdzamy, czy piłeczka dotarła do lewej, bądź prawej krawędzi ekranu. Jeżeli tak to musimy sprawdzić, czy znajduje się ona w obrębie paletki, czy też nie. Sprawdza to dla nas metoda checkForFail, któa zwraca prawdę, jeżeli piłeczka znajduje się poza paletką. Jeżeli piłeczka nie została odbita to wywołujemy funkcję startNewGame() oraz natychmiast wychodzimy z funkcji calcBall(). Ostanią rzeczą do sprawdzenia jest, czy piłeczka dotarła do dolnej lub górnej krawędzi ekranu. Jeżeli tak to musimy zmienić kierunek jej ruchu w pionie na przeciwny.

    void calcBats() {
      enum DIRECTION dir;
      for(int x = 0; x < 2; x++) {
        dir = getBatDir(batButtons[x][0], batButtons[x][1]);
        if(dir == UP) {
          batPos[x][1]--;
        } else if(dir == DOWN) {
          batPos[x][1]++;
        }

        if(batPos[x][1] < 0) batPos[x][1] = 0;
        if(batPos[x][1] > oledHeight - batHeight) batPos[x][1] = oledHeight - batHeight;
      }
    }

W funkcji calcBats mamy już trochę mniej pracy do zrobienia :) Po pierwsze mamy 2 paletki, więc należałoby obliczyć pozycje obu za jednym zamachem. Zrobi to dla nas pętla for, w której jest praktycznie cały kod funkcji. W pętli na początku sprawdzamy w jakim kierunku powinniśmy przesunąć daną paletkę (z pomocą przychodzi funkcja getBatDir, któa zwraca nam typ wyliczeniowy. Teraz na podstawie zmiennej dir możemy określić, czy przesuwamy paletkę w górę, czy tez w dół. Teraz pozostało nam już tylko sprawdzić, czy paletka nie wychodzi poza ekran i gotowe :)

    void draw() {
      display -> clearDisplay();
      display -> drawFastVLine(batPos[0][0], batPos[0][1], batHeight, 1);
      display -> drawFastVLine(batPos[1][0], batPos[1][1], batHeight, 1);
      display -> drawPixel(ballPos[0], ballPos[1], 1);
      display -> display();
    }

Jak to mawiają Anglicy last but not least, czyli funkcja draw(). Tutaj zajmujemy się wyrenderowaniem całej planszy. Najpierw czyścimy ekran, następnie rysujemy obie paletki, piłeczkę i wywoujemy metodę display(), która narysuje nam na ekranie to, co chcemy. Należy zwrócić tu uwagę, że do metod obiektu display odwołujemy się za pomocą strzełki (->), a nie kropki jak to zwykle bywa, gdyż jest to wskaźnik na obiekt.

Otwórzmy teraz główny plik projektu, czyli Pong.ino:

Poza tym wszystkim, co w artykule o podłączaniu wyświetlacza znajdziemy tutaj konstrukcję obiektu klasy Game oraz następującą implementacje funkcji loop:

void loop() {
  if(millis() - lastRefresh >= 20) {
    pong.calcBall();
    pong.calcBats();
    pong.draw();
    lastRefresh = millis();
  }
}

Jak widać wywołujemy tu nasze 3 omawiane wcześniej metody co 20 milisekund. Oznacza to, że powinniśmy otrzymać około 50 klatek na sekundę. Całkiem płynnie :)