Eksperymenty z FPGA (20). Konsola retro

Eksperymenty z FPGA (20). Konsola retro

Piłka w grze. Teraz czas na zawodników! Najpierw przygotujemy przeciwnika, a później połączymy wszystkie komponenty dodając logikę PONGa. Płytka „Rysino” zmieni się w retro konsolę.

Przeciwnik

Rysunek 1. Schemat blokowy przeciwnika

Rysunek 1 pokazuje schemat blokowy przeciwnika. Mamy tu dwa wejścia:

  • ball_y – aktualna pozycja piłki,
  • game – stan wysoki oznacza, że trwa rozgrywka, pozwala na resetowanie pozycji.

Oponent, podobnie jak gracz, wykonuje ruch jedynie w osi pionowej. Rejestr opp_y przechowuje jego obecne położenie. W każdym kroku symulacji (czyli dla każdej ramki) ma on dwie możliwości: ruch w górę, albo w dół o V pikseli. Decyzję o kierunku podejmuje komparator porównujący obecną pozycję z położeniem piłki. Ostatnim krokiem obliczeń jest saturacja. Sprawdzamy tu, czy nowa pozycja mieści się na ekranie. Jeżeli nie, wpisujemy skrajne górne, albo dolne położenie. Na końcu wynik zatrzaskujemy jeszcze w dodatkowym rejestrze.

Implementację znajdziemy na listingu 1. Na początku (linie 10...17) definiujemy sobie kilka stałych: rozmiar ekranu, wielkość paletki oraz piłki. Na końcu zostaje ilość pikseli, o które może poruszyć się przeciwnik pomiędzy dwoma klatkami obrazu. W dalszej części (linie 18...23) widzimy wejścia i wyjścia. Ich funkcje znamy już z diagramu z rysunku 1.

Listing 1. Implementacja przeciwnika (16_PONG/opponent.sv)

10 module opponent #(
11   parameter V = 480,
12   parameter H = 640,
13   parameter LOG_Y = $clog2(V),
14   parameter HIGH = 100,
15   parameter BALL_HIGH = 20,
16   parameter VELOCITY = 2
17 ) (
18   input wire clk,
19   input wire rst,
20   input wire ce,
21   input wire [LOG_Y-1:0] ball_y,
22   output logic [LOG_Y-1:0] opponent_y
23 );

40   always_ff @(posedge clk)
41     if (!rst) begin
42       opponent_y <= (V-HIGH)/2;
43       oy <= (V-HIGH)/2;
44     end else begin
45       if (ce) begin
46         oy_tmp <= oy + (((by - HIGH/2 + BALL_HIGH/2) > oy) ? VELOCITY : -VELOCITY);
47       end
48
49      if (ce1) begin
50         if (oy_tmp < Y_MIN)
51           oy <= Y_MIN;
52         else if (oy_tmp > Y_MAX - HIGH)
53           oy <= Y_MAX-HIGH;
54         else
55           oy <= oy_tmp;
56     end
57             opponent_y <= oy;
58         end

Najciekawsze rzeczy dzieją się w bloku always_ff. Na samym początku obsługujemy reset (linie 41...43). W dalszej kolejności widzimy dwie fazy obliczeń. W liniach 45...47 obliczmy nową pozycję. W zależności od względnej pozycji piłki i paletki dodajemy, albo odejmujemy stałą VELOCITY. Druga część, która zostanie wykonana w kolejnym takcie zegara, ma miejsce w liniach 49...56. Sprawdzamy, czy obliczona wartość mieści się w zadanym zakresie. Na samym końcu, w wierszu 57 zapisujemy wyliczona wartość w rejestrze wyjściowym.

Listing 2. Generowanie wymuszeń w testbenchu (16_PONG/opponent_tb.sv)

39 initial begin
40   ball_y <= 0;
41   #100 ball_y <= V-1;
42   #100 ball_y <= V/2;
43   #100 ball_y <= 2*V/3;
44   #100 $stop;
45 end

Kolejnym krokiem jest przygotowanie symulacji. Tym razem wymuszenia będą bardzo proste. Widzimy je na listingu 2. Po prostu zadajemy kolejne położenia piłki i czekamy na reakcje naszego zawodnika. Symulację uruchamiamy w programie ModelSim poleceniem:

PONG

Teraz przygotujemy logikę gry. Będziemy tu sprawdzać, czy nie nastąpiło zwycięstwo, albo klęska. Skorzystamy tu z jednego z najpopularniejszych wzorców projektowych w świecie FPGA: maszyny stanów. Najpierw zdefiniujemy typ wyliczeniowy reprezentujący stan (listing 4):

  • INIT – po resecie, wyświetla ekran startowy,
  • PONG – trwa rozgrywka,
  • VICTORY – wyświetla ekran zwycięstwa,
  • DEFEAT – wyświetla ekran klęski.
Listing 4. Typ wyliczeniowy określający stany FSM(16_PONG/pong_pkg.sv)

10 package pongPkg;
11
12   typedef enum logic [1:0] {
13     INIT,
14     PONG,
15     VICTORY,
16     DEFEAT
17   } state_t;
18
19 endpackage : pongPkg

Przejścia pomiędzy kolejnymi stanami prezentuje rysunek 4. Zaczynamy od INIT. Czekamy w nim na naciśnięcie przycisku, który spowoduje przejście do stanu PONG i rozpoczęcie rozgrywki. Tutaj sprawdzamy, czy piłeczka uderzyła w krawędź ekranu. Jeżeli tak, przechodzimy do któregoś ze stanów: VICTORY albo DEFEAT. W nich wyświetlamy informację i znów czekamy na naciśnięcie przycisku. Wtedy przejdziemy do stanu PONG i rozgrywka zacznie się od nowa.

Rysunek 4. Maszyna stanów

Implementacje zaczniemy od funkcji, która sprawdza, czy nastąpiło odbicie. Widzimy ją na listingu 5.

Listing 5. Funkcja sprawdzająca, czy piłka odbiła się od paletki (16_PONG/pong.sv)

66 function automatic check_ball([V_BIT-1:0]pos);
67   
68   logic signed [V_BIT:0]pos_ball = $signed({1’b0, pos}) - $signed(BALL);
69   logic signed [V_BIT:0]pos_rec = pos + RECTANGLE_V;
70   logic signed [V_BIT:0]ball = ball_y;
71   
72   check_ball = (ball < pos_ball || ball > pos_rec);
73 endfunction

Przyjmuje ona jedną zmienną: pozycję paletki pos. Zwróćmy uwagę na słowo kluczowe automatic pojawiające się przed nazwą funkcji. Dzięki temu, każde wywołanie będzie traktowane jako osobny byt. Coś jakbyśmy po prostu zrobili kopiuj-wklej zawartego w niej kodu. Wewnątrz naszej funkcji sprawdzamy, czy piłka trafiła w paletkę, czy może jednak wypadła z boiska.

Listing 6. Maszyna stanów (16_PONG/pong.sv)

75 always_ff @(posedge clk)
76   if (!rst)
77     state <= INIT;
78   else if (ce) begin
79     case (state)
80       INIT: state <= button ? PONG : INIT;
81       PONG:
82         if (reflection)
83           if (ball_x < 320)
84             state <= check_ball(encoder) ? DEFEAT : PONG;
85           else
86             state <= check_ball(opp_y) ? VICTORY : PONG;
87       VICTORY: state <= button ? PONG : VICTORY;
88       DEFEAT: state <= button ? PONG : DEFEAT;
89     endcase
90   end

Samą maszynę stanów znajdziemy na listingu 6. Jak zwykle zaczynamy od resetu. Następnie w instrukcji case wykonujemy akcje dla kolejnych stanów. Dla stanu PONG korzystamy z sygnału reflection zwracanego przez moduł piłki. Informuje on, że nastąpiło odbicie i należy sprawdzić, czy rozgrywka się nie zakończyła. Sposób połączenia maszyny stanów z pozostałymi modułami pokazuje rysunek 5. Wejściami modułu jest informacja o nowej ramce (ce) oraz pozycja gracza (encoder) oraz stanie przycisku (button). Wyjściem są natomiast położenia piłki, przeciwnika i gracza oraz aktualny stan.

Rysunek 5. Połączenie maszyny stanów z pozostałymi modułami

Mamy już gotową logikę gry. Czas aby ją przetestować. Na listingu 7 widzimy wymuszenia, które spowodują rozegranie dwóch gier. W pierwszej będziemy sterować paletką gracza (wiersze 54...62). W drugiej natomiast zostawimy ją bez ruchu w oczekiwaniu klęski. Symulację uruchamiamy w programie ModelSim poleceniem:

GUI

Przygotowaliśmy już logikę gry. Dzięki temu, że jest oddzielona od generowania obrazu mogliśmy ją łatwo przetestować. Teraz zostało nam jeszcze przygotowanie GUI. Użyjemy przygotowanych wcześniej modułów: wyświetlania prostokąta oraz bitmapy. Uproszczony schemat prezentuje rysunek 7. Wejściem są: stan, pozycje paletek i piłki oraz sygnały synchronizujące z modułu VGA. Za generowanie obrazu rozgrywki odpowiadają trzy „prostokąty”. Tak jak poprzednio ich wyjścia kolorów są sumowane za pomocą instrukcji lub. Dodatkowo używamy trzech modułów from_mem. Wyświetlą one trzy ekrany: startowy oraz informujące o zwycięstwie albo przegranej.

Rysunek 7. Schemat blokowy GUI

Wyboru aktualnego ekranu dokonuje multiplekser, na podstawie aktualnego stanu. Jego implementacja jest przedstawiona na listingu 8.

Listing 8. Multiplekser portu VGA (16_PONG/pong_disp.sv)

24 always_ff @(posedge clk)
25   case (state)
26     INIT: vga <= vga_i;
27     PONG: vga <= vga_p;
28     VICTORY: vga <= vga_v;
29     DEFEAT: vga <= vga_d;
30   endcase

Używamy tu po prostu instrukcji case. Warto zwrócić uwagę o ile udało się skrócić ten kod dzięki użyciu struktury opisującej port VGA. Grafiki dla ekranów startowych (rysunek 8) utworzyłem przy pomocy narzędzia [3].

Rysunek 8. Ekrany startowe

Jest to działający w przeglądarce edytor piksel artu. Możemy w nim zdefiniować własne palety kolorów. Listing 9 pokazuje taką, która odpowiada naszej 3-bitowej implementacji. Przy tworzeniu swojej możemy po prostu wkleić podane kody kolorów.

Listing 9. Paleta kolorów (16_PONG/images/colors.txt)

ff0000,0000ff,00ff00,ffff00,ff00ff,00ffff,000000,ffffff
Rysunek 9. Schemat blokowy gry PONG

Ostatnim krokiem, który dzieli nas od rozgrywki jest integracja wszystkich modułów. Uproszczony schemat prezentuje rysunek 9. Moduły encoder i debounce obsługują sterowanie od użytkownika. Przekazują one parametry do modułu PONG. Równolegle działa moduł VGA. Ostatnia część PONG display odbiera od nich sygnały i generuje odpowiedni obraz. Włączamy program Quartus. Ładujemy projekt rectangle.qpf. Jako moduł top wybieramy pong_top.sv. Uruchamiamy budowę. Programujemy płytkę i gramy. Uzyskany efekt możemy zobaczyć na fotografii 1 oraz pod koniec filmu [2].

Fotografia 1. Gra PONG w trakcie działania

Podsumowanie

Przygotowaliśmy grę PONG. Teraz czas na rozgrywkę. Ale to nie koniec zabawy z VGA. Już w przyszłym miesiącu przygotujemy terminal znakowy.

Rafał Kozik
rafkozik@gmail.com

[1] Repozytorium http://bit.ly/33uYPxs
[2] Film demonstrujący działanie projektu https://bit.ly/3fZkHsP
[3] Narzędzie do rysunków: https://bit.ly/3x8R3sx
Artykuł ukazał się w
Elektronika Praktyczna
lipiec 2021

Elektronika Praktyczna Plus lipiec - grudzień 2012

Elektronika Praktyczna Plus

Monograficzne wydania specjalne

Elektronik maj 2022

Elektronik

Magazyn elektroniki profesjonalnej

Raspberry Pi 2015

Raspberry Pi

Wykorzystaj wszystkie możliwości wyjątkowego minikomputera

Świat Radio maj - czerwiec 2022

Świat Radio

Magazyn krótkofalowców i amatorów CB

Automatyka Podzespoły Aplikacje kwiecień 2022

Automatyka Podzespoły Aplikacje

Technika i rynek systemów automatyki

Elektronika Praktyczna maj 2022

Elektronika Praktyczna

Międzynarodowy magazyn elektroników konstruktorów

Elektronika dla Wszystkich maj 2022

Elektronika dla Wszystkich

Interesująca elektronika dla pasjonatów