[175] Pong na oscyloskopie cz. 1
Pong powstał jeszcze w roku 1972 jako jedna z pierwszych gier telewizyjnych i – mimo prymitywizmu – od razu podbił serca nowej grupy graczy, gustujących właśnie w grach związanych z telewizorem. Co ciekawe, powstała nawet polska wersja tej zabawki, jedna z nielicznych produkowanych po tej stronie „żelaznej kurtyny”. W Pongach produkowanych masowo wykorzystywano specjalizowane układy scalone serii AY-3-8500 i kineskop.
My oczywiście zatrudnimy Arduino i prosty oscyloskop, o którym pisałem ostatnio. Ma on ograniczone pasmo i szereg niedoskonałości, ale właśnie dlatego chcę użyć tego zabytku – obraz będzie bardzo retro, a poza tym da szansę na drugie życie grata, do niczego innego już nienadającego się.
Oscyloskop to nie telewizor i wygaszenie plamki, bez specjalnego wejścia temu służącego, jest niemożliwe. Dlatego będziemy widzieli przeszkadzające „duchy”, czyli linie łączące grafiki, lecz taki jest urok korzystania z prostego sprzętu. Dodam jeszcze, że projekt od początku będzie wykorzystywał 32-bitowe Arduino. Nie znaczy, że starsza wersja nie da rady, ale nowa da nam zapas mocy, jak również istotną tutaj większą częstotliwość PWM, co pozwoli użyć wyższych częstotliwości roboczych gry i mniejszych pojemności filtrujących.
Na chwilkę wróćmy jeszcze do owego filtra. Jak już poprzednio pisałem, użyjemy wyjść PWM, pracujących tym razem już nie z maksymalną częstotliwością 62,5, a 480 kHz. Skąd taka wysoka wartość? Da ją częstotliwość taktująca PWM w Uno R4 przy 100 krokach. Taką wartość przyjąłem dla porządku i jest w pełni zadowalająca dla naszych działań. Wspomniany filtr, na każdej z linii składający się z rezystora o wartości kilooma i kondensatora 100 nF, zmieniłem zastępując pojemność wartością 33 nF. Przejdźmy więc do konkretów. Ponieważ jednak projekt jest złożony, będziemy działać etapami. Zaczniemy od kawałków grafik tylko, żeby skupić się na razie na tym i w kolejnych krokach nie będzie to już dla nas tajemnicą.
Na początek narysujmy dwie nieruchome paletki. Założyłem sobie, że pole naszej gry wykorzysta całą płaszczyznę ekranu oscyloskopu, a więc będziemy używać napięć od zera do pięciu woltów, co da nam układ współrzędnych 100x100 px. Dlaczego tak? Dla uproszczenia obliczeń: PWM dla Uno R4 wymaga podawania wartości w procentach. Jeśli będziemy się poruszać na takiej siatce, unikniemy obliczeń zmiennoprzecinkowych. Ekrany oscyloskopów są na tyle małe, że to nijak nie wpłynie na pogorszenie czytelności. Oczywiście oscyloskop należy tak wyregulować, by zero wyznaczało dolną krawędź, a pięć woltów – górną. Podobnie z lewą i prawą stroną. Do tego proponuję wykorzystać szkic, który prezentowałem ostatnio.
const byte inputY = 9; // Port wejścia Y oscyloskopu (Kanał 2).
const byte inputX = 10; // Port wejścia X oscyloskopu (Kanał 1).
int z = 1000;
void setup() {
pinMode(inputY, OUTPUT); // Inicjuj port wejścia Y oscyloskopu.
pinMode(inputX, OUTPUT); // Inicjuj port wejścia X oscyloskopu.
}
void loop() {
digitalWrite(inputX, LOW); // Lewy dolny róg.
digitalWrite(inputY, LOW);
delayMicroseconds(z);
digitalWrite(inputX, HIGH); // Prawy dolny róg.
digitalWrite(inputY, LOW);
delayMicroseconds(z);
digitalWrite(inputX, HIGH); // Prawy górny róg.
digitalWrite(inputY, HIGH);
delayMicroseconds(z);
digitalWrite(inputX, LOW); // Lewy górny róg.
digitalWrite(inputY, HIGH);
delayMicroseconds(z);
}
Wyświetla on prostokąt, wystawiając kolejno zera i pełne napięcia. Po rozciągnięciu obrazu i wycentrowaniu go możemy przystąpić do działań.
#include "pwm.h" // Biblioteka PWM dla architektury Uno R4
PwmOut pwmX(10); // Obiekt sterujący sprzętowym PWM dla pinu 10 (oś X).
PwmOut pwmY(9); // Obiekt sterujący sprzętowym PWM dla pinu 9 (oś Y).
const byte paddleLength = 8; // Połowa szerokości paletek.
byte paddleCenterY = 50; // Środek paletek.
void setup() {
pwmX.begin(480000.0f, 0.0f); // Częstotliwość pracy X = 480 kHz = 100 kroków.
pwmY.begin(480000.0f, 0.0f); // Częstotliwość pracy Y = 480 kHz = 100 kroków.
}
void loop() {
drawPaddle(0, paddleCenterY); // Rysuj lewą paletkę.
drawPaddle(100, paddleCenterY); // Rysuj prawą paletkę.
}
void drawPaddle(byte positionX, byte centerY) { // Rysowanie paletki.
byte beginY = centerY - paddleLength; // Oblicz adres Y początku paletki.
byte endY = centerY + paddleLength; // Oblicz adres Y końca paletki.
pwmX.pulse_perc(positionX); // Wyślij adres X początku paletki.
for (byte n = beginY; n <= endY; n++) { // Rysuj paletkę, dobierz liczbę kroków do czytelności.
pwmY.pulse_perc(n); // Stawiaj kolejne kropki na osi Y.
delayMicroseconds(1000); // Wprowadź opóźnienie dające wyraźny rysunek paletki.
}
}
Zaczniemy od tego, co związane jest z 32-bitową wersją Uno. Potrzebujemy biblioteki obsługującej PWM, deklaracji obiektów i ustawień, wymuszających pracę PWM z częstotliwością 480 kHz. A także deklaracji pinów, które teraz stworzą port oscyloskopu: dziewiąty należy podłączyć do wejścia, czyli osi Y, a dziesiąty do osi X, którą w samym oscyloskopie należy przekierować na to wejście z generatora lokalnego. O tym pisałem w poprzednich artykułach. Określimy sobie szerokość paletek paddleLength – ustaliłem 16 px. Zapiszmy ją jednak jako połowę szerokości – uprości to późniejsze obliczenia. Środek paletek będzie wyznaczać zmienna paddleCenterY.
Rysowanie przeprowadzamy w głównej pętli: ładujemy kolejno: dane dla osi X położenia paletki oraz jej środek na osi Y, po czym przechodzimy do podprogramu rysującego. Tam odzyskujemy zmienne i wyznaczamy początek paletki, czyli odcinka na osi Y oraz jego koniec. Teraz możemy wystawiamy wartość dla osi X oscyloskopu, czyli położenie odcinka w poziomie i zaczynamy kreślić pionową serię punktów między początkiem, a końcem odcinka, czyli narysujemy go. Każdy punkt wyświetlamy przez milisekundę, żeby był wystarczająco jasny. Tutaj skok wynosi jeden, możemy go rozrzedzić do 3 bądź 4. Po kompilacji ujrzymy dwie – na razie nieruchome paletki połączone słabo świecącą linią.

No cóż, na nią nie ma rady, linia ta będzie widoczna, zwłaszcza w przypadku tak słabych oscyloskopów, które przy próbach ograniczania wpływu filtru będą rozmywać grafiki. Zmodyfikujmy teraz szkic, dodając możliwość wpływania potencjometrem na położenie jednej z paletek. W tym celu użyję pokładowego potencjometru z płytki edukacyjnej TME, podłączonego do pinu A1.
#include "pwm.h" // Biblioteka PWM dla architektury Uno R4
const byte pot = A1; // Port potencjometru sterującego prawą paletką (gracz).
PwmOut pwmX(10); // Obiekt sterujący sprzętowym PWM dla pinu 10 (oś X).
PwmOut pwmY(9); // Obiekt sterujący sprzętowym PWM dla pinu 9 (oś Y).
const byte paddleLength = 8; // Połowa szerokości paletek.
const byte paddleTop = 100 - paddleLength; // Górna granica ruchu środka paletek.
byte paddleLCenterY = 50; // Środek lewej paletki (komputera).
byte paddleRCenterY = 50; // Środek prawej paletki (gracza).
void setup() {
pwmX.begin(480000.0f, 0.0f); // Częstotliwość pracy X = 480 kHz = 100 kroków.
pwmY.begin(480000.0f, 0.0f); // Częstotliwość pracy Y = 480 kHz = 100 kroków.
}
void loop() {
paddleRCenterY = map(analogRead(pot), 0, 1023, paddleLength, paddleTop); // Oblicz położenie środka prawej paletki (gracza).
drawPaddle(0, paddleLCenterY); // Rysuj lewą paletkę (komputera).
drawPaddle(100, paddleRCenterY); // Rysuj prawą paletkę (gracza).
}
void drawPaddle(byte positionX, byte centerY) { // Rysowanie paletki.
byte beginY = centerY - paddleLength; // Oblicz adres Y początku paletki.
byte endY = centerY + paddleLength; // Oblicz adres Y końca paletki.
pwmX.pulse_perc(positionX); // Wyślij adres X początku paletki.
for (byte n = beginY; n <= endY; n++) { // Rysuj paletkę, dobierz liczbę kroków do czytelności.
pwmY.pulse_perc(n); // Stawiaj kolejne kropki na osi Y.
delayMicroseconds(1000); // Wprowadź opóźnienie dające wyraźny rysunek paletki.
}
}
Naturalnie musimy go teraz zadeklarować. Komputerową paletkę nadal pozostawimy nieruchomą, jednak położenie drugiej będzie zależne od potencjometru, więc musimy rozdzielić zmienne dla każdej paletki. Dopiszemy po prostu litery L i R – w zależności od tego, o której paletce mowa.
Główną pętlę zaczynamy od odczytania pozycji potencjometru i przekształcenia jej w położenie środka paletki prawej. Zauważmy, że korzystamy tutaj z nowej zmiennej paddleTop, którą liczymy przed kompilacją. Dla programu jest to stała, określająca maksymalną dopuszczalną pozycję paletki i skoro nie zmienia się ona, można ją obliczyć właśnie tutaj i podać kompilatorowi jako stałą. Reszta pracuje tak samo: ładujemy koordynaty do podprogramu, który nam to narysuje, najpierw ruchomą paletkę lewą, a potem wyśrodkowaną prawą. Możemy się teraz pobawić takim jeszcze martwym, bo pozbawionym piłki Pongiem, w którym mamy już panowanie nad naszą paletką. Czas więc domalować piłkę.
#include "pwm.h" // Biblioteka PWM dla architektury Uno R4
const byte pot = A1; // Port potencjometru sterującego prawą paletką (gracz).
PwmOut pwmX(10); // Obiekt sterujący sprzętowym PWM dla pinu 10 (oś X).
PwmOut pwmY(9); // Obiekt sterujący sprzętowym PWM dla pinu 9 (oś Y).
const byte paddleLength = 8; // Połowa szerokości paletki.
const byte paddleTop = 100 - paddleLength; // Górna granica ruchu środka paletki.
byte paddleLCenterY = 50; // Środek lewej paletki (komputera).
byte paddleRCenterY = 50; // Środek prawej paletki (gracza).
int ballX = 50; // Pozycja X piłki.
int ballY = 50; // Pozycja Y piłki.
int ballXSpeed = 2; // Prędkość pozioma piłki i jej kierunek.
int ballYSpeed = 1; // Prędkość pionowa piłki i jej kierunek.
unsigned long prevMillis = 0; // Czas poprzedniego stanu zegara systemowego.
const unsigned long refresh = 20; // Czas odświeżania logiki gry w ms
void setup() {
pwmX.begin(480000.0f, 0.0f); // Częstotliwość pracy X = 480 kHz = 100 kroków.
pwmY.begin(480000.0f, 0.0f); // Częstotliwość pracy Y = 480 kHz = 100 kroków.
}
void loop() {
unsigned long currentMillis = millis(); // Sczytaj czas systemowy.
if (currentMillis - prevMillis >= refresh) { // Jeśli minął czas trwania jednej klatki gry...
prevMillis = currentMillis; // Przenieś sczytany czas systemowy do zmiennej do porównań.
paddleRCenterY = map(analogRead(pot), 0, 1023, paddleLength, paddleTop); // Oblicz położenie środka prawej paletki (gracza).
ballX += ballXSpeed; // Wyznacz nowy adres X piłki.
ballY += ballYSpeed; // Wyznacz nowy adres Y piłki.
if (ballY > paddleLCenterY && paddleLCenterY < paddleTop) { // Jeśli piłka znajduje się powyżej środka lewej paletki...
paddleLCenterY++; // Przesuń lewą paletkę (komputera) w górę.
} else if (ballY < paddleLCenterY && paddleLCenterY > paddleLength) { // Jeśli piłka znajduje się poniżej środka lewej paletki...
paddleLCenterY--; // Przesuń lewą paletkę (komputera) w dół.
}
if (ballY >= 100) { // Jeśli piłka osiągnęła górną krawędź...
ballYSpeed = -ballYSpeed; // Zmień kierunek poruszania się piłki w dół.
ballY = 100; // W razie przekroczenia zakresu ustaw piłkę na górze ekranu
} else if (ballY <= 0) { // Jeśli piłka osiągnęła dolną krawędź...
ballYSpeed = -ballYSpeed; // Zmień kierunek poruszania się piłki w górę.
ballY = 0; // W razie przekroczenia zakresu ustaw piłkę na dole ekranu.
}
if (ballXSpeed < 0 && ballX <= 0 && abs(ballY - paddleLCenterY) <= paddleLength) { // Odbij piłkę jeśli...
ballXSpeed = -ballXSpeed; // Zmień kierunek jej poruszania.
} else if (ballXSpeed > 0 && ballX >= 100 && abs(ballY - paddleRCenterY) <= paddleLength) { // Odbij piłkę jeśli...
ballXSpeed = -ballXSpeed; // Zmień kierunek jej poruszania.
}
if (ballX < 0 || ballX > 100) { // Jeśli piłka nie została przechwycona paletką (znalazła się poza ekranem w osi X)...
ballX = 50; // Centruj jej ustawienie.
ballY = 50;
}
}
drawPaddle(0, paddleLCenterY); // Rysuj lewą paletkę (komputera).
drawPaddle(100, paddleRCenterY); // Rysuj prawą paletkę (gracza).
pwmX.pulse_perc(ballX); // Wyślij adres X pozycji piłki.
pwmY.pulse_perc(ballY); // Wyślij adres Y pozycji piłki.
delayMicroseconds(1000); // Wprowadź opóźnienie dające wyraźny rysunek piłki.
}
void drawPaddle(byte positionX, byte centerY) { // Rysowanie paletki.
byte beginY = centerY - paddleLength; // Oblicz adres Y początku paletki.
byte endY = centerY + paddleLength; // Oblicz adres Y końca paletki.
pwmX.pulse_perc(positionX); // Wyślij adres X początku paletki.
for (byte n = beginY; n <= endY; n++) { // Rysuj paletkę, dobierz liczbę kroków do czytelności.
pwmY.pulse_perc(n); // Stawiaj kolejne kropki na osi Y.
delayMicroseconds(1000); // Wprowadź opóźnienie dające wyraźny rysunek paletki.
}
}
W deklaracjach przybyło zmiennych związanych głównie z piłką. Elementy obsługujące prawą paletkę już znamy – podobnie jak poprzednio jest sterowana potencjometrem. Nowością będzie wykorzystanie zegara systemowego do rysowania kolejnych klatek gry. Jest to istotne ze względu na jej płynność i uniezależnienie się od stopnia skomplikowania bieżących treści, o ile tylko zdążą się one wygenerować w obrębie czasu przeznaczonego na obsługę klatki. Ustaliłem sobie ten czas na dwadzieścia milisekund i teraz będę tę przerwę obliczał. Tak więc dalszy ciąg pętli wykona się dopiero po upływie owego czasu.
Gdy więc minie 20 ms, nastąpi znane nam już przeliczenie pozycji potencjometru na położenie lewej paletki, ale na razie bez jej wyświetlenia. Przystępujemy teraz do przeliczenia nowego adresu położenia piłki. Szybkość jej ruchu możemy dobierać deklarując stałe speed. Teraz stworzymy automat, który będzie przesuwał paletkę obsługiwaną przez komputer. Ta będzie się poruszać w kierunku zgodnym z kierunkiem poruszania się piłki względem środka paletki. Trzeba ustawić pułapkę na zderzenie piłki z krawędziami: górną i dolną. W każdym z tych wypadków składowa ruchu w pionie zmieni znak, dzięki czemu piłka powędruje z powrotem w kierunku środka ekranu. Dodatkowo, na wszelki wypadek, gdyby koordynaty w pionie wypchnęły piłkę poza ekran, narzucimy jej miejsce po zderzeniu ze ścianą na konkretne wartości: 100 albo 0.
Czas na analizę odbicia piłki przez paletkę. Badany warunek jest dość złożony: w pierwszym przypadku analizujemy ruch piłki przy lewej paletce. Jeśli kolejno: piłka porusza się w tę stronę, tzn. zmienna szybkości jest mniejsza od zera oraz zbliżyła się już do osi X paletki jak również jej pozycja na osi Y znajduje się w przedziale, w którym znajduje się także paletka, możemy uznać, że piłka została odbita. Po zmianie znaku zmiennej szybkości będzie wędrować w przeciwną stronę. Zdarzenie po drugiej stronie ekranu jest symetryczne. W końcu gdy piłka nie zostanie odbita, czyli parametry dla osi X przekroczą wartości legalne, uznamy że straciliśmy punkt. Pozycja piłki zresetuje się do środkowej. Reszta programu wygląda jak poprzednio.
Teraz możemy sobie już pograć, aczkolwiek szybko zauważymy, że gra staje się monotonna, a z komputerem – przy tych parametrach startowych – wygrać nie sposób. Czas na poprawienie grywalności – ale o tym już napiszę w kolejnym artykule.

Inne artykuły z tej kategorii
Fotograficzny kalkulator raz jeszcze









































































