Przeciwnik
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.
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.
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.
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.
Implementacje zaczniemy od funkcji, która sprawdza, czy nastąpiło odbicie. Widzimy ją na listingu 5.
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.
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.
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.
Wyboru aktualnego ekranu dokonuje multiplekser, na podstawie aktualnego stanu. Jego implementacja jest przedstawiona na listingu 8.
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].
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.
ff0000,0000ff,00ff00,ffff00,ff00ff,00ffff,000000,ffffff
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].
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
[2] Film demonstrujący działanie projektu https://bit.ly/3fZkHsP
[3] Narzędzie do rysunków: https://bit.ly/3x8R3sx