fbpx
Kategorie
Publicystyka Zrób to sam

Napisz własnego Tetrixa

Dwa lata temu opublikowałem artykuł sugerujący, by początkujący programiści nie płacili za kurs programowania. Jeśli zaczynasz od zera, to – moim zdaniem – wydanie kilku czy kilkunastu tysięcy zł na szkolenie jest złym wyborem. Zamiast tego weź bezpłatny urlop, zaoszczędzoną gotówkę wydaj na życie i ucz się programowania 8-12h dziennie przez kilka lub kilkanaście tygodni.

Niedawno sprawdziłem, ile czasu sam potrzebowałbym na realizację proponowanego w artykule pierwszego projektu. Napisałem w języku C# grę Tetrix – działający w trybie tekstowym klon Tetrisa. Niniejszy tekst powstaje w oparciu o twitterową relację na żywo oraz repozytorium kodu na GitHubie, do którego wrzucałem kod i animowane zrzuty ekranu.

Aha, gdyby ktoś chciał zacząć komentarz od kpiny, że kilka linijek kodu z pierwszego kroku zajęło mi aż 11 minut – robiłem więcej, niż samo tylko programowanie. Każdy krok składał się z:

  • pisania kodu
  • nagrania animowanego GIF-a
  • wrzucenia kodu i GIF-a do repozytorium
  • wrzucenia GIF-a z opisem na Twittera
  • (mniej lub bardziej udanego) ignorowania komci na Twitterze

Czas start (0 minut od startu)

Plik z grą z tego kroku

Gra w bieżącej postaci składa się z jednej znaczącej linijki kodu:
Console.WriteLine("Tetrix!");

Jak widać, dostajemy właśnie to – napis „Tetrix!” wyświetlony w konsoli:

Rysowanie studni / 11 minuta / +11 m

Plik z grą w tej wersji

Aby móc grać w Tetrisa, potrzebna będzie studnia. Planuję w każdej klatce animacji odrysowywać ekran od nowa. W funkcji DrawScreen() piszę po konsoli tekstowej ustawiając kursor w wybranym miejscu za pomocą polecenia Console.SetCursorPosition(x,y) i „rysując” za pomocą funkcji Console.Write(napis).

Dla ułatwienia dodałem numerowanie poziomów studni. Numeracja idzie od dołu, bo wysokość studni będziemy mogli regulować, natomiast zero będzie zawsze najniższym poziomem. Liczymy od zera, jak programiści!

Efekt:

Śmieci na dnie studni / 17 minuta / +6 m

Różnica w stosunku do poprzedniej wersji (diff) – tutaj
Plik z grą – tutaj

Chcemy mieć trochę istniejących klocków na dnie studni, aby w późniejszym etapie można było testować kolizje. Wprowadzamy dwuwymiarową tablicę (zmienna well) z zawartością studni. Zero oznacza puste miejsce, jedynka oznacza miejsce zajęte czyli „zamarzniętą” zawartość składającą się z wcześniejszych klocków.

Testujemy mechanizm rysowania zawartości studni poprzez dodanie kilku zajętych miejsc.

Rysowanie klocków / 46 minuta / +29 m

Diff tutaj
Plik tutaj

Na dobry początek dodałem definicje trzech klocków (trzymam je w liście dwuwymiarowych tablic z zapalonymi i zgaszonymi pikselkami). Rysowanie klocka jest realizowane przez funkcję DrawPiece(), bieżący klocek wyróżnia się z zamarzniętych pól odmiennym wyglądem „pikseli”.

Na razie klocek wisi nieruchomo w powietrzu, dowolnym klawiszem można cyklicznie przełączać go między trzema wzorami.

Wzory klocków i ich obrotów będą pochodzić z tetrisowego standardu SRS.

Poruszanie klockiem / 57 minuta / +11 m

Diff tutaj
Plik tutaj

Czas na przesuwanie bieżącego klocka (klawisze kursora lewo, prawo, dół). Jak widać na obrazku poniżej, nie mamy jeszcze żadnej detekcji kolizji ze ścianami.

Dodałem definicje brakujących klocków, przełączamy je spacją. Klawisz escape kończy działanie programu.

Detekcja kolizji / 118 minuta / +61 m

Diff tutaj
Plik tutaj

Detekcja kolizji działa następująco:

  1. najpierw sprawdzamy, czy pożądany ruch nie sprawi, że któryś z zapalonych pikseli bieżącego klocka wjedzie w ścianę
  2. jeśli nie, to sprawdzamy, czy pożądany ruch nie sprawi, że któryś z zapalonych pikseli bieżącego klocka wjedzie w podłogę
  3. jeśli nie, to sprawdzamy, czy pożądany ruch spowoduje kolizję z „zamarzniętymi” klockami w studni

Gdy nie zachodzi żadna z tych możliwości, ruch może zostać wykonany.

Przy tego typu sprawdzeniach szalenie łatwo pomylić się w obliczeniach o jeden piksel, więc dodałem sobie pomocniczy komunikacik ze współrzędnymi klocka i wykrytej kolizji. Gdybyśmy tworzyli oprogramowanie w sposób profesjonalny, trzeba byłoby opatrzyć tę funkcję zestawem testów automatycznych. To zwiększy szanse wykrycia zaskakująco częstej sytuacji, gdy naprawiamy jeden błąd a wprowadzamy inny.

Aby ułatwić testy, dodałem kilka „zamarzniętych” klocków wiszących przy ścianach.

Zamrażanie opadniętych klocków / 126 minuta / +8 m

Diff tutaj
Plik tutaj

Zaskakująco prosta zmiana – jeśli przy ruchu w dół wykrywamy kolizję, piksele bieżącego klocka są wpisywane w tablicę opisującą zawartość studni. Następnie resetujemy współrzędne ruchomego klocka, aby znów wskoczył na górę.

Obracanie klocków / 156 minuta / +30 m

Diff tutaj
Plik tutaj

Definicja pojedynczego klocka (tablica dwuwymiarowa, piksele x/y) zostaje zastąpiona definicją czterech klocków (tablica trójwymiarowa, wariant/x/y), po jednym dla każdego wariantu obrotu. Klocek „kwadrat” zachował jeden wariant, bo niezależnie od obrotu wygląda zawsze tak samo. Uważny czytelnik odnajdzie w podlinkowanym wyżej kodzie klocek „Z” zawierający jeden wariant obrotu niepasujący do pozostałych.

Od tej pory klawisz „góra” nie przesuwa klocka do góry, tylko go obraca. Detekcja kolizji nie działa poprawnie przy obrotach, naprawimy to za kilka chwil.

Znikanie wypełnionych linii i punktacja / 181 minut / +25 m

Diff tutaj
Plik tutaj

Kolejna zmiana, która wydawała się skomplikowana, a okazała się całkiem prosta. Po zamrożeniu bieżącego klocka skanujemy studnię od dołu, wykrywamy zapełnione linie i usuwamy je (przepisując jednocześnie zawartość wyższych linii oczko niżej).

Liczymy też punkty, których przychód zależy od liczby linii znikniętych w danym ruchu – 1200, 300, 100 lub 40 punktów za odpowiednio 4, 3, 2 i 1 linię.

Rzucanie „cienia” / 192 minuty / +11 m

Diff tutaj
Plik tutaj

Aby ułatwić trafienie klockiem w wybrane miejsce na dnie studni, zaznaczamy kropkami piksele, które zostaną zapełnione. Od strony technicznej te piksele to dodatkowy klocek, zsuwany w dół aż do miejsca wystąpienia kolizji i tam rysowany z użyciem kropek.

Detekcja kolizji przy obrotach / 197 minut / +5 m

Diff tutaj
Plik tutaj

Prosta zmiana – do funkcji wykrywającej kolizję przekazujemy nie tylko pożądane współrzędne, ale także pożądany obrót klocka. Animacji brak, bo ciężko zilustrować poprawną detekcję kolizji przy obrotach.

Opadanie klocka co sekundę / 219 minut / +22 m

Diff tutaj
Plik tutaj

Tutaj trochę oszukujemy – opadanie klocka nie działa tak, jak powinno w prawdziwym Tetrisie, tfu, Tetriksie. We wprowadzonym rozwiązaniu co sekundę symulujemy timerem wciśnięcie klawisza „dół” (funkcja Tick), o ile od ostatniego wywołania funkcji użytkownik nie wcisnął jakiegoś klawisza. Jeśli tak było, symulowane zdarzenie nie ma miejsca.

Oznacza to, że można dowolnie długo zastanawiać się nad ruchem, przeskakując klockiem w lewo i w prawo. Mogę żyć z takim uproszczeniem.

Dodanie losowych nowych klocków / 235 minut / +16 m

Diff tutaj
Plik tutaj

Dopiero tutaj naprawiłem felerny wariant obrotu klocka „Z” sprzed ponad godziny. Spacja nie podmienia już sztucznie klocka na inny, tylko zrzuca klocek w dół. Pozycja początkowa i wzór kolejnego klocka są teraz losowane.

Studnia została wyczyszczona. W grę niby da się grać, ale… nie da się przegrać.

Obsługa zakończenia gry / 275 minut / +40 m

Diff tutaj
Plik tutaj

Gdy nowo dostawiony klocek powodowałby kolizję w miejscu pojawienia, gra kończy się i wyświetlany jest odpowiedni komunikat. Wciśnięcie Entera rozpoczyna nową rozgrywkę.

Przy okazji restartu wyszedł na jaw błąd wykrywania kolizji z dnem studni. Jeśli zapalony był tylko prawy-dolny pixel (klocek S obrócony w prawo), warunek nie był spełniony i gra się zawieszała. Gdy testowałem koniec gry zrzucając szybko wszystkie klocki, problem występował średnio raz na 28 prób (tylko jeden z siedmiu klocków w jednym z czterech wariantów obrotu) – dłuższą chwilę zajęło mi więc ustalenie, co jest nie tak.

Prawidłowe pozycje startowe klocków / 294 minuty / +19 m

Commit w repozytorium zawiera tylko obrazek, więc kod jest do obejrzenia w następnym punkcie.

Ta zmiana dostosowuje Tetrixa do oryginalnych reguł Tetrisa – klocki zawsze mają wymaganą pozycję startową, tzn. obrót nie jest już losowany a każdy klocek zawsze pojawia się w tym samym miejscu, możliwie blisko środka studni.

Spostrzeżenie – gdybym taką zmianę wprowadził wcześniej, o wiele trudniej byłoby mi natrafić na błąd opisany w poprzednim kroku.

Zrównoważone losowanie klocków / 317 minut / +23 m

Diff tutaj
Plik tutaj

Poprzednio każdy nowy klocek był losowany od nowa spośród siedmiu wariantów, od teraz w użyciu jest tzw. double-bag system (losowanie dwuworeczkowe?). Objaśnijmy najpierw ideę jednego woreczka: na początku gry bierzemy wszystkie siedem klocków i losujemy kolejność, wg której wejdą do gry. Gdy ta pula się wyczerpie, losujemy nowy komplet. Taki system balansuje rozgrywkę i daje nam gwarancję, że wymarzone proste klocki będą oddalone od siebie o nie więcej niż 12 innych klocków. Możliwe jest też, że nastąpią po sobie (ostatni klocek z woreczka i pierwszy klocek z kolejnego woreczka).

Po co nam więc system dwuworeczkowy? Chcemy zapowiadać, jak będzie następny klocek – gdy bierzemy z woreczka ostatni element, musimy od razu pokazać pierwszy klocek z kolejnej puli. Najprostszym rozwiązaniem jest więc pamiętanie dwóch woreczków naraz.

Odepchnięcia od ścian / 331 minut / +14 m

Tu wchodzimy w niuanse i warianty zasad, niektóre z nich pozwalają profesjonalnym graczom wstrzelić klocek w niedostępne szczeliny. Przykład obrotu w lewo, który udaje się dzięki tablicom odepchnięć systemu SRS:

W szóstej godzinie pisania Tetrixa zapał już mi się kończył, więc zaimplementowałem jedynie odpychanie od ściany. Jeśli próba obrotu klocka kończy się wykryciem kolizji, ale przesunięcie go o jedno lub dwa pola w lewo lub prawo pozwalałoby na obrót, to dokonujemy takiego właśnie przemieszczenia.

Na obrazku poniżej widać klocek „T” który odpycha się w ten sposób najpierw od lewej a potem od prawej ściany.

Na tym live-tweetowany rozwój Tetrixa został zakończony.

Dlaczego ten kod jest taki pokraczny?

Wiem, co mogli sobie pomyśleć czytelnicy, którzy zerknęli do GitHuba: dlaczego ten kod jest taki nieuporządkowany? Gdzie moje 20 lat doświadczenia w programowaniu, skoro Tetrix wygląda jak dzieło amatora?

Otóż – takimi właśnie prawami rządzą się prototypy. Moim celem było jak najszybsze dotarcie do etapu działającej gry, którą można testować i dostrajać. Udało się to w ciągu kilku godzin! Podczas pisania dostrzegłem obszary wymagające szczególnej uwagi (detekcja kolizji i odepchnięcia) oraz dopracowania (timer spychający bieżący klocek w dół).

Gdyby Tetrix miał zostać wydany jako gotowy produkt, 90% kodu należałoby napisać od nowa. Pomogłoby to w późniejszym utrzymaniu i rozwoju gry. Wydzielenie komponentów i interfejsów pozwoliłby na równoległą pracę kilku osób. Z całą pewnością jednak nie udałoby się wówczas napisać działającej gry w czasie krótszym, niż jeden dzień roboczy.

Początkującym pójdzie dużo wolniej!

Proces tworzenia Tetrixa podzieliłem na szesnaście kroków, z których każdy zajął mi średnio 20 minut. Jeśli dopiero myślisz o nauce programowania, twoje tempo będzie znacznie wolniejsze!

Wielokrotnie zawędrujesz w ślepą uliczkę, która nie przybliży cię do rozwiązania. Będziesz kasować kod i próbować ponownie. W końcu zabraknie ci pomysłów a walenie głową w ścianę nie pomoże. Rzucisz to w diabły, mamrocząc że nie nadajesz się do tego całego programowania. A potem, nagle – na spacerze albo pod prysznicem – nastąpi olśnienie. Wrócisz do komputera i tanecznym krokiem ominiesz przeszkodę, czując się jak ktoś, kto właśnie osiągnął kolejny etap wtajemniczenia w nowym dla siebie fachu.

Sugeruję delektować się tym uczuciem, bo nie trwa długo. Kolejny bloker jest oddalony o kwadrans lub dwa.

Ile to jest dużo wolniej?

No ale do rzeczy – ile razy wolniej będzie szło początkującemu? Większość z opisanych wyżej kroków powinna dać się zrealizować w jeden wieczór (na sztukę). Ile to godzin? Nie wiem, od dwóch do pięciu? Ważne – jedna kilkugodzinna sesja programowania jest lepsza, niż kilka jednogodzinnych. Zbudowanie w głowie obrazu, który chcemy przełożyć na kod, zajmuje czas i za każdym razem trzeba to robić od nowa.

Kilka kroków produkcji Tetrixa będzie trudniejszych, niż pozostałe. Podziel je na części! Zamiast kroku „detekcja kolizji” implementuj trzy prostsze: „detekcja kolizji przy ruchu w lewo”, „detekcja kolizji przy ruchu w prawo”, „detekcja kolizji przy ruchu w dół”. Jeśli trzeba, dodaj kolejne warianty: „detekcja kolizji o ścianę przy ruchu w lewo” oraz „detekcja kolizji o inne klocki przy ruchu w lewo”.

Zostawiaj w kodzie notatki dla samego siebie. Pisz, co chcesz osiągnąć albo do czego ma służyć dana funkcja albo blok kodu. W ten sposób łatwiej wrócisz w to miejsce po kilku dniach.

Nie wahaj się realizować własnych pomysłów! Nie podoba ci się cień klocka? Zrezygnuj z tej funkcji. Chcesz mieć opcję wysadzania następnego klocka? Dodaj ją!

A co, jeśli nie idzie? Jeśli masz za sobą dwadzieścia kilkugodzinnych sesji a Tetrix nadal nie działa? Cóż, może kariera programisty nie jest dla ciebie. Pamiętaj jednak, że IT to więcej, niż programowanie. Przypominam raz jeszcze o tym filmie HRejterów (kręconym na poważnie): „Czy w wieku 30, 40 lub 50 lat można zacząć pracę w IT?”.

Tetrix jest już spalony? Napisz Samotnixa!

Jeśli napiszesz własnego Tetrixa podglądając moje rozwiązania, możesz postrzegać swoje dzieło jako niesamodzielne. Napisz wówczas kolejną grę, tym razem stuprocentowo sam/sama! Co powiesz na Samotnika? Oczywiście nazwiemy go Samotnix, aby uniknąć roszczeń o znak towarowy ze strony starożytnych Rzymian.

Oto możliwe ulepszenia działającej gry:

  • oba warianty, 33- i 37-polowy
  • funkcja cofnięcia jednego ruchu
  • funkcja cofnięcia dowolnie wielu ruchów
  • sygnalizacja stanu, w którym nie da się wygrać (trudne)

A może Warcabix?

Jeśli umiesz napisać Tetrixa i Samotnixa, to znaczy, że potrafisz poprawnie obsłużyć bieżący stan gry. Co powiesz na przeszukiwanie stanów, które mogą pojawić się w przyszłości? Napisz Warcabixa ze sztuczną inteligencją grającą z człowiekiem!

Taka „sztuczna inteligencja” również może powstawać przyrostowo. Oto przykładowe kroki ewolucji:

  • Warcabix robi losowe ruchy
  • Warcabix robi damkę, jeśli może, w przeciwnym razie jak wyżej
  • Warcabix robi bicie, jeśli może, w przeciwnym razie jak wyżej
  • Jeśli Warcabix ma dwa możliwe bicia, wybiera dłuższe, w przeciwnym razie jak wyżej

Powyższe kroki ograniczają się do jednego ruchu. Czas spojrzeć w przyszłość:

  • Jeśli Warcabix ma dwa możliwe bicia, wybiera takie, w którym bijący klocek sam nie zostanie zbity, w przeciwnym razie jak wyżej
  • Warcabix sprawdza wszystkie swoje możliwe ruchy i wszystkie możliwe odpowiedzi przeciwnika, następnie wybiera jeden z ruchów w których nie przegrywa
  • Warcabix sprawdza wszystkie swoje możliwe ruchy i wszystkie możliwe odpowiedzi przeciwnika, następnie wybiera jeden z ruchów w których poprawia (ew. nie pogarsza) proporcje liczby swoich pionków względem pionków przeciwnika

Potem możemy przeszukiwać nie dwa ruchy do przodu, lecz trzy, cztery i więcej – aż do przeszukania wszystkich możliwych wariantów przyszłości. Jeśli takie przeszukiwanie trwa zbyt długo, zmniejsz liczbę początkowych pionów. Potem możesz ulepszać przeszukiwanie przyszłych stanów gry, eliminując badanie wariantów w których człowiek wygrywa. Jest to tak zwany algorytm alfa-beta. A jeśli Warcabix wykryje swoje gwarantowane zwycięstwo, niech koniecznie wyświetli graczowi taką informację.

Do dziś pamiętam to dziwne, niesamowicie fajne, lecz jednocześnie mocno niepokojące uczucie, gdy po raz pierwszy napisałem program, z którym nie potrafiłem wygrać w grę logiczną.



O autorze: zawodowy programista od 2003 roku, pasjonat bezpieczeństwa informatycznego. Rozwijał systemy finansowe dla NBP, tworzył i weryfikował zabezpieczenia bankowych aplikacji mobilnych, brał udział w pracach nad grą Angry Birds i wyszukiwarką internetową Microsoft Bing.

3 odpowiedzi na “Napisz własnego Tetrixa”

No ładnie, ale masz za sobą parę ładnych lat doświadczenia w programowaniu. Ja swój pierwszy program pisałem w wieku 35 lat w autobusie po drodze do pracy. Tylko tak miałem czas się nim zająć. I nie dlatego, że miałem ochotę na programowanie, tylko miałem konkretny problem, do którego nie było gotowców. Pół roku mi to zajęło, ale działa produkcyjnie od pięciu lat do dzisiaj. Prawie nic już w nim nie zmieniam.
Programowanie dla zabawy nigdy mnie nie wciągnęło.

Fajny pomysł na wpis i bardzo ciekawy eksperyment. Sam, gdy zaczynałem uczyć się programowania, to stworzyłem grę w węża i pamiętam ile czasu i pracy w to włożyłem. Ciekawe o ile szybciej byłbym w stanie napisać taki prototyp obecnie 🤔

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *