Devlog lamera #2

Postępy i zmagania z wysupłaniem gry z silnika raycastowego i paczki pomysłów.
A zatem jest tak - jak pisałem w poprzednim odcinku devloga, pracuję nad portem i modernizacją silnika raycastowego z C++ na FreeBasic. W zasadzie to pracowałem, ponieważ większość prac w tej kwestii mam zakończonych... chociaż przez chwilę myślałem, że nici z projektu, i to nie za bardzo wiadomo czemu.
A działo się tak: po względnie krótkich pracach nad implementacją kodu na rysowanie sprite'ów zrobiłem brudny hack na silniku roboczym na generowanie kilkunastu na losowych pozycjach. Zabrałem się do testów i... crash. Odpaliłem ponownie... crash. Po raz trzeci - wylądowałem w labiryncie, ale po chwili chodzenia - znowu crash. Program się kompilował, więc nie był to błąd składni. Uważnie przejrzałem dodane linie kodu, ale nie, nie popełniłem żadnej literówki jak ostatnio, mimo to wiedziałem z całą pewnością, że pies pogrzebany był w kodzie sprajtowym, gdyż nic innego nie było tykane. Jaskiniową metodą wrzucania polecenia Sleep (bez parametru działa jak "press any key") co parę instrukcji doszedłem do tego, że biznes pada na pysk po obliczeniu pozycji i wielkości danego sprite'a, po sprawdzeniu czy jest widoczny - podczas pobierania piksela tekstury do wrzucenia na ekran. Kod w oryginale wyglądał tak:
Uint32 color = texture[sprite[spriteOrder[i]].texture][texWidth * texY + texX];
if((color & 0x00FFFFFF) != 0) buffer[y][stripe] = color;
Dobra, wiedziałem co się sypie, ale nie miałem za cholerę pojęcia - czemu. Kod w gruncie rzeczy identyczny z tym, który był wykorzystywany do rysowania ścian plus test na przezroczystość (druga linijka: jeśli kolor tekstury AND biały jest inny od zera to rysuj, albo po ludzku - rysuj wszystko co nie jest czarne). Wywalanie działo się jednak w pierwszej linijce, więc to też nie przezroczystość. Po godzinie żmudnych testów z których w gruncie rzeczy nic nie wychodziło, przypomniałem sobie, że C++ i FB domyślnie nieco inaczej traktują castowanie między typami (zaokrąglanie itp.). Przed pobraniem kolorów dorzuciłem cztery linijki sanity check (jeśli texX < 0, texX = 0, jeśli texX > 255, texX = 255...). Zadziałało! Wyszło na to, że podczas zaokrąglania obliczeń konkretnego punktu na teksturze mój kod czasem wyłaził poza wymiary tekstury, co powodowało wywrotkę całego biznesu. Mogłem w pełni cieszyć się sceną rysowaną ze sprite'ami, najpierw bez przezroczystości:
A potem z przezroczystością:
HIGHLY IRREGULAR
Jako sprite'y wygarnąłem na szybko parę awatarów znajomych, a przezroczystość "dodałem" w MS Paint wykorzystując standardowo kolor magenta (#FF00FF). Niestety MS Paint uznał, że to doskonały moment na interpolację, przez co pojawiły się fioletowe nacieki powyżej.
OK, silnik ma wszystkie podstawowe funkcje. No, nie wszystkie, ale oświetlenie musi poczekać aż system mapy będzie odrobinę bardziej rozwinięty. Właśnie - mapy. Aby uwolnić się od ręcznego modyfikowania generatora lochów za każdym cholernym razem musiałem opracować edytor map. To było akurat bardzo proste - w ciągu dosłownie godziny miałem działającą rzecz pracującą na mapach 80x80, obsługującą 1 typ podłogi i 4 typy ścian, czyli dokładnie tyle ile mam w tej chwili tekstur-placeholderów, oraz oczywiście zapisywanie i wczytywanie (plik binarny .lmap zawierający grid i pozycję początkową gracza oraz .lname, który pewnie przemianuję na .ldat, który w tej chwili zawiera tylko nazwę poziomu, a w odległej przyszłości obiekty i eventy):
Mając to gotowe zaczął się żmudny etap portowania raycastera z prymitywnej pętli działającej z poziomu jednego pliku .bas na plik #Include, engine.bi, który będzie się nadawał do wykorzystywania i w innych projektach. Wymaga to zmodularyzowania i rozproszenia funkcjonalności wallcast, floorcast, spritecast, oraz sterowania, a także wczytywania i inicjalizowania mapy. Pierwsza iteracja przenoszenia kodu zaczęła się powodzeniem, tzn. wszystko się kompilowało (ergo wszystkie zmienne zostały prawidłowo przemianowane i nie wywaliłem się przy przekazywaniu danych między nowopowstałymi procedurami).
Odpaiłem zatem silnik i bum, crash to desktop.
Grr.
Z początku myślałem że ładowanie tekstur coś psuło, choć w gruncie rzeczy nie powinno - mimo to na ekranie pojawiała się ostatnia załadowana tekstura, więc... Podłubałem trochę, przestawiłem inicjalizację pointerów tekstur na po inicjalizacji bufora ekranowego, tekstury znikły, ale błąd nie. Sondując Sleepem doszedłem do tego, że wallcastowanie przebiega prawidłowo do momentu, w którym pobierany jest pixel z tekstury ściany, czyli to samo co wywalało mi spritecast, z tym że na tym etapie błąd nie miał prawa występować.
fug
Byłem 100% pewien, że tym razem koordynaty są obliczane prawidłowo, zanim zacząłem pracę na wszelkie możliwe sposoby testowałem reakcję silnika na kąty i prędkości, a tu było najzwyklejsze w świecie statyczne rysowanie ściany o pole naprzód i po bokach. Więc "oczywiście" coś musiało być nie tak z ładowaniem/inicjalizacją tekstur. Ale nie, tutaj wszelkie możliwe wygibasy kodowe nie pomagały, no i błąd nie występował już przy ładowaniu. Zacząłem wreszcie sprawdzać wszystko po kolei - w tym miejscu muszę jednak wyjaśnić jedną rzecz, jak program działa w chwili obecnej.
Generalnie szkielet gry jedzie w następujący sposób:
- wczytywanie pliku konfiguracji i inicjalizacja stałych, ogólnodostępnych zmiennych, matryc, wskaźników itp. (defs.bi)
- inicjalizacja kamery (utworzenie zmiennych pozycji, kierunku, płaszczyzny - engine.bi)
- otwarcie ekranu
- wczytanie tekstur, wywalone do osobnej procedury, choć na razie jest ona dość prosta
- wczytanie wskazanego pliku mapy do obiektu CurrentMap (w tej chwili zawiera siatkę, pozycję początkową, nazwę poziomu) i do oko.posX / oko.posY (kamera - pozycja)
- inicjalizacja mapy, to jest przerzucenie gridu z CurrentMap do globalnej matrycy map(x,y) wykorzystywanej przez raycast.
- jednorazowy raycast, koniec programu
Machinacje z siatką mapy są niezbędne gdyż nie chcę engine.bi uzależniać zbytnio od konkretnej struktury na potrzeby bieżącego projektu. W przyszłości map(x,y) nie będzie globalne, a będzie właściwością obiektu renderera, ale tu muszę się dokładnie przeszkolić w kwestii namespaces, konsturktorów i podobnych, żeby nie dorzucić nowych problemów zanim nie jestem na to gotowy.
Zatem zacząłem sprawdzać jak wygląda wymiana danych na kolejnych etapach. Po paru testach miałem mniej-więcej orientację: o ile grid mapy czytany i inicjalizowany był prawidłowo, to położenie gracza/kamery z jakiegoś powodu lądowało na 0,0, a nie 2,2 jak miałem zapisane. Ręczne ustalenie przypisaniem prawidłowego położenia sprawiło, że silnik wreszcie zadziałał prawidłowo i wyrysował oczekiwaną scenę - wiedziałem już co tym razem wywala mój silnik.
Ale czemu tak się dzieje? Kod ładowania mapy był żywcem wzięty z edytora i tam pozycja gracza ładowana była prawidłowo. Zakres ważności danych kamery (>oko.posX / oko.posY) był prawidłowo oznaczony. Czepiając się brzytwy dorzuciłem do CurrentMap parametry pozycji startowej, a przypisanie tej pozycji do kamery wywaliłem do inicjalizacji mapy. Wreszcie zadziałało!
Dopiero później domyśliłem się skąd przybyło mi tyle bólu głowy, winowajcą okazały się niezgodne typy danych. Otóż pozycja gracza zapisywana była do pliku jako UShort, 16-bitowa liczba całkowita (i tak za duża, w sumie powinienem jechać na byte), natomiast kamera przechowywała dane pozycji jako 64-bitową liczbę podwójnej precyzji. Ładowanie z pliku mapy bezpośrednio do oko.pos szukało 2x64 bitów w miejscu gdzie były 2x16, a skoro bitów nie było dość nawet na jedną zmienną, obie dostawały wartość 0. Dodatkowy krok ładowania danych do liczby UShort, a następnie przerzucenie z tego UShort do oko.pos rozwiązało ten dylemat.
Zatem mam silnik, mam prymitywny edytor map, mapy się ładują, scenka rysuje - pora na ruch w labiryncie. Tu nie mogę po prostu przełożyć kodu, gdyż w tym programie ruch będzie skokowy, co jedno pole, a nie co jego drobny ułamek. Ale to jeszcze kwestia przyszłości.
Do następnego!