Dziś mijają równo trzy lata od rozpoczęcia prac nad moją grą.
1 marca 2022 roku to dzień, w którym utworzyłem katalog oraz projekt gry w Lazarusie. Kupa kodu napisana, efekty bardzo zadowalające, zapał taki sam (jeśli nie większy niż) na początku. Tak właściwie to prace trwają krócej, bo łącznie przez kilka miesięcy nie było czasu na dłubanie, a potem wyszedł SDL3 i portowanie kodu zajęło ze dwa miesiące (przy okazji zrobiłem spory refactoring i co nieco zoptymalizowałem, głównie alokacje). Finalnie, mniej więcej dwa i pół roku klepania kodu za mną.
Początkowo planowałem, że pracy będzie na ~pięć lat, więc jestem na półmetku. Choć bardziej skłaniam się ku sześciu latom, bo nie chcę poświęcać jakości kodu i gry kosztem wcześniejszego release'u. No nic, wracam do klepania kodu — nie ma czasu na świętowanie.
@Escanor16: tak, ~dobrze pamiętasz. Zmieniłem, bo potrzebowałem zmiany — nowy etap, nowy nick, stary ja. A poprzedni już dawno temu przestał być aktualny, więc czas na zmianę i tak nadszedł.
Całkiem niedawno pisałem o tym, że dodałem do kernela UI wsparcie kontrolek do wpisywania tekstu i z tej okazji również przygotowałem demówkę, w której mogłem się bawić testowymi kontrolkami oraz śledzić komunikację kernela UI z kontrolkami, czyli wymianę zdarzeń i ich kolejkowanie. Demówka ta przy okazji służyła mi do testowania kursorów własnego pomysłu, które nie tylko obsługują animowane kształty (tak jak typowe kursory systemu Windows), ale też zmiana kształtu może być wykonywana w sposób animowany.
Zauważyłem, że znimowana zmiana kształtu kursora (w uproszczeniu fade-in/fade-out) wygląda super, ale nie zawsze. Niektóre kształty, np. łapka z wyprostowanym palcem (symbolizująca element do kliknięcia) oraz łapka bez tego palca (symbolizująca wciśnięty przycisk myszy lub przeciąganie elementu), niewiele się różnią, przez co animacja zmiany wygląda nie najlepiej. Postanowiłem więc dodać do formatu swoich kursorów informacje o tym, które pary kształtów mają być zamieniane w sposób animowany, a które nie, tak aby wszystko wyglądało ładnie.
Każdy kursor może wspierać od jednego do piętnastu kształtów. Kształt to nic innego jak po prostu typ wskaźnika, tak jak w przypadku kursorów znanych z systemu Windows — strzałka, łapka, klepsydra itp. Aby poinstruować silnik na temat tego, czy dana para kształtów ma być zmieniana w sposób animowany czy nie, dla każdej takiej pary wystarczy flaga logiczna.
Kształtów jest 15
, więc możliwych kombinacji par jest 15 * 15
, czyli 225
. Zmiana kształtu ze samym sobą nie ma sensu i nie jest używana w silniku, więc flagi po przekątnej tablicy 15×15
nie są potrzebne — odpada 15
flag, zostaje 210
. Idąc dalej, trzeba wziąć pod uwagę to, że dla danej pary kształtów nie trzeba dwóch flag, a tylko jedna — odpada połowa flag, zostaje 105
.
Jak te flagi są przechowywane w pamięci? Ot w formie dwuwymiarowej tablicy, w rozmiarze 15×15
, gdzie indeks dla każdego wymiaru to ID kształtu (dwa wymiary, ID dwóch kształtów). Flagi po przekątnej są zgaszone (bo nie są używane), natomiast po obu stronach przekątnej są dwa podzbiory flag, wartości tych podzbiorów są lustrzane, względem tej przekątnej. Lustrzany podzbiór flag przydaje się do tego, aby mając ID dwóch kształtów, móc pobrać flagę nie zważając na to które ID ma określać który wymiar.
Tablica zadeklarowana jest jako spakowana bitowo — jeden bit na jedną flagę, łącznie 30
bajtów.
Aby móc wygodnie i szybko edytować zawartość tablicy flag, przyda się nowe okienko dialogowe. Każda flaga logiczna to dwa możliwe stany, flag trzeba 105
, więc wystarczy 105
checkboxów, ułożonych w formie trójkąta (bez flag dla przekątnej i tych lustrzanych). Postawiłem na formularzu 105
checkboxów. Krew mnie zalała…
Designer w Lazarusie przy tak gigantycznej liczbie kontrolek jak 105
(to żart) ssie niemiłosiernie… Kiedy zaznaczyłem wszystkie te checkboxy, to przesunięcie takiej grupy kontrolek trwa ze dwie sekundy. Przy czym jeśli ciągnie się myszą taką grupę, to co kilka pikseli designer aktualizuje pozycję ich wszystkich, każdorazowo gotując CPU przez 2 sekundy. Normalnie pokaz slajdów… W runtime lepiej nie jest, bo pokazanie formularza z setką checkboxów też ssie — do tego stopnia, że okno pojawia się niemal w sposób animowany. Nie chcę nawet wiedzieć ile by to trwało, gdybym te kontrolki pospinał ze sobą w edytorze kotwic, tak aby uniezależnić układ kontrolek od DPI — wszechświat by pewnie szybciej powstał niż takie okno…
Trochę to śmieszne, że CPU jest w stanie wykonać setki milionów instrukcji w każdej sekundzie, ale to za mało, aby przesunąć w oknie setkę prostokątów. Cóż, tak w akcji wyglądają kilotony abstrakcji obiektowych, masa bezsensownych zabezpieczeń i słabe algorytmy (tragiczna złożoność obliczeniowa). Kurtyna.
Olałem setkę checkboxów i taką macierz zaimplementowałem ręcznie, renderując ją w kontrolce typu TPaintBox
:
Aby dynamicznie renderowane w tej kontrolce checkboxy wyglądały jak te systemowe, skorzystałem z ThemeServices
, czyli poprosiłem system operacyjny o to, aby mi namalował na pomocniczych bitmapach checkboxy dla różnych stanów, które następnie renderuję w TPaintBox
, w wyznaczonych miejscach. Ostatecznie wybrałem, że jeśli kursor znajduje się nad którąś komórką, to renderowany jest checkbox hot, a także wszystkie dla danego wiersza i kolumny. Natomiast wszystkie pozostałe, nie hoverowane, renderowane są jako zablokowane (zafajkowane lub nie), tak aby jak najmocniej odróżniały się od tych hoverowanych).
Żeby było wiadomo których kształtów dotyczy hoverowana flaga, podświetla się nazwa kursora dla aktywnego wiersza i kolumny, a także — w dwóch TPaintBox
po prawej stronie, renderowane są główne stemple tych kształtów. Do ich renderowania skorzystałem z już istniejącego kodu, którego edytor używa do renderowania komórek kształtów w głównym oknie edytora. Dzięki temu w tych dwóch okienkach kształt renderowany jest razem z tłem (w aktualnie ustawionym stylu/kolorze), a także belka tytułowa z nazwą kształtu.
Fajnie to wygląda i wygodnie się z tego narzędzia korzysta. Żeby w przyszłości nie musieć wracać do kodu kodu edytora, od razu zaimplementowałem możliwość zmiany zaznaczenia również dla całego rzędu i kolumny, co realizuje się poprzez trzymanie odpowiednio klawisza Shift i/lub Alt w trakcie klikania LPM.
Efekt końcowy:
Format swoich kursorów zaprojektowałem w taki sposób, że dla każdego kształtu z osobna przewidziana była osobna tekstura atlasu. Jeśli kształt był statyczny, to w atlasie posiadał tylko jeden stempel, a jeśli animowany, to wszystkie stemple jako klatki animacji. Kursory są tak maleńkie (raptem kilkanaście na kilkanaście pikseli), że używanie 15
mikroskopijnych tekstur nie ma za bardzo sensu, skoro może to być jedna tekstura i zawierać wszystkie klatki wszystkich obsługiwanych przez kursor kształtów.
Przerobiłem więc kod silnika oraz edytora, tak aby kursor posiadał tylko jedną teksturę. Niewiele pracy to kosztowało, a przynajmniej ten temat nie będzie mi wiercił dziury w głowie, że robiłem 15
alokacji w GPU zamiast jednej.
Tekstura atlasu dla testowego kursora (tego co widać na powyższym gifie) wygląda tak:
To była ostatnia zmiana, jaką chciałem zrobić przed kolejnym krokiem dewelopmentu. Silnik jest już gotowy, teraz zabieram się za projektowanie podsystemu obsługi map świata oraz edytora map, obiektów, questów itd., a także renderera takich map. Czeka mnie niezła przeprawa — pochłonie łącznie z 50kLoC.
Tablica zadeklarowana jest jako spakowana bitowo
Free Pascal ma coś takiego, że nie musisz ręcznie robić tego pakowania działaniami bitowymi?
@Manna5: można bez problemu pakować bitowo, jest do tego modyfikator bitpacked.
U mnie deklaracja wygląda tak:
Animations: bitpacked array [WNC_CURSOR_SHAPE, WNC_CURSOR_SHAPE] of TWnc_Bool8 readonly;
WNC_CURSOR_SHAPE
to makro z zakresem dostępnych ID kursorów, natomiast readonly
to puste makro, którym dodatkowo oznaczam wszelkie pola struktur, których nie powinienem modyfikować poza modułem deklaracji struktur. W edytorze mam prostą tablicę jako pole klasy z triggerami animacji:
FBuffer: bitpacked array [0 .. EDITOR_CURSOR_SHAPE_NUM - 1, 0 .. EDITOR_CURSOR_SHAPE_NUM - 1] of Boolean;
We Free Pascalu można bit-pakować macierze i struktury, a także — tak samo jak w C — deklarować pola bitowe w strukturach. Choć akurat składnia do określania rozmiarów pól bitowych jest nieco upierdliwa, bo zamiast rozmiaru pola w bitach, podać trzeba zakres wartości. Z jednej strony trochę to wkurza, ale z drugiej strony daje więcej możliwości niż to samo w C.
TL;DR: demówka do testowania okna i dekoracji — WtNC — decoration.zip
Na końcu wpisu jest krótki opis działania oraz mała instrukcja obsługi.
Ponieważ gry wideo to forma sztuki, deweloperzy gier od zarania dziejów konkurowali ze sobą i prześcigali się w implementacji ficzerów, mających na celu przykuć wzrok i wprawić w zachwyt graczy. Interfejsy w grach to nierzadko arcydzieła, pokaz skilla programistów i artystów. A co z dekoracją okna? No właśnie — systemowa ramka okna z tytułem i przyciskami nijak nie pasuje stylem do interfejsu gry, więc pasuje coś z tym faktem zrobić.
Długo na to czekałem, głównie ze względu na trwające prace nad wydaniem stabilnej biblioteki SDL3. W ostatnich dwóch tygodniach ostro pracowałem nad implementacją własnej dekoracji okna, prace dobiegły końca i z tej okazji nowy wpis, w gigantycznym skrócie opisujący jak to wygląda z perspektywy silnika gry.
Najpierw należy usunąć systemową ramkę, czyli stworzyć okno z flagą SDL_WINDOW_BORDERLESS
. Ważne jest tutaj to, że jeśli nasza dekoracja ma umożliwiać rozciąganie okna za pomocą myszy oraz maksymalizację, trzeba też użyć flagi SDL_WINDOW_RESIZABLE
. W ten sposób informuje się system, że ma nie dodawać standardowej dekoracji, czyli po prostu ma wyświetlać na ekranie sam obszar klienta okna.
Jak dodać własną dekorację do okna? Odpowiedź jest prosta — nijak, dekoracji się nie dodaje, a po prostu tworzy się wirtualną i renderuje ją w obszarze klienta, zamiast wokół niego. A biorąc pod uwagę to, że klient okna (czyli obszar, nad którym mamy pełną kontrolę) zawierać będzie również elementy dekoracji, możemy robić co nam się żywnie podoba, stworzyć dekorację z dowolną liczbą i pozycją elementów, renderować je z dowolnymi efektami wizualnymi itd. Róbta co chceta.
Żeby nie obliczać wszystkiego w każdej klatce, obiekt okna gry posiada pola z obszarami wirtualnych elementów dekoracji. Te obszary używane są zarówno w trakcie przetwarzania zdarzeń inputu, jak i w trakcie renderowania zawartości okna.
Obiekt okna przechowuje aktualny obszar całego okna (rozmiar na ekranie), obszar dla stylu okienkowego (do przywracania z trybów pełnoekranowych oraz zapamiętywania pomiędzy sesjami), oraz obszary wszystkich elementów dekoracji: paska tytułowego, wszystkich systemowych przycisków znajdujących się na nim, obszaru klienta okna, obszaru przeznaczonego do renderowania klatki w zadanych proporcjach, obszaru renderowania wirtualnego kursora oraz obszaru paska stanu.
Sporo tego, a to i tak nie wszystko, bo do tego jest jeszcze kilka flag oraz pól przechowujących kluczowe dane layoutu okna, które wpływają na rozmiar okna w stylu okienkowym. Głównie mowa o proporcjach pikseli (zakres od kwadratowych po prostokątne, 8:7
) oraz widoczność paska stanu.
Żeby informować system o tym, gdzie w obszarze okna znajdują się elementy dekoracji, rejestruje się callback, za pomocą funkcji SDL_SetWindowHitTest. Callback ten wywoływany jest przez SDL za każdym razem, gdy system potrzebuje informacji o tym co znajduje się w danym pikselu w oknie (z reguły na podstawie współrzędnych kursora w oknie). Callback otrzymuje współrzędne punktu, sprawdza czy punkt ten leży na krawędzi okna, czy na belce, czy w obszarze wirtualnego klienta i zwraca odpowiednią wartość (SDL_HitTestResult).
System na podstawie zwróconej wartości ustawia odpowiedni kształt kursora i pozwala przeciągać lub rozciągać okno za pomocą myszy. Są też dostępne dodatkowe ficzery, znane ze standardowej dekoracji — np. podwójne kliknięcie LPM w obszarze belki okna wyzwala maksymalizację lub przywrócenie okna.
Rejestruje się go funkcją SDL_SetWindowsMessageHook. Jest on wywoływany gdy pętla komunikatów okna (która obsługiwana jest w całości przez SDL) otrzyma od systemu jakikolwiek komunikat. SDL przekazuje go do callbacku, tak aby aplikacja mogła na niego zareagować — zmodyfikować go lub zjeść. Jeśli callback nie zjadł komunikatu, SDL przepakowuje go do formy zdarzenia i umieszcza w swojej kolejce zdarzeń, która potem przetwarzana jest przez aplikację (np. za pomocą SDL_PollEvent).
Po co mi hook na systemowe komunikaty, skoro silnik aktualizuje swój stan na podstawie zdarzeń? A no jest jeden powód, bardzo ważny — wsparcie rozciągania okna myszą, z zachowaniem proporcji klatki gry. Aby to wykonać, należy wyłapać w tym hooku komunikat WM_SIZING i jeśli użytkownik trzyma Ctrl, zmodyfikować rozmiar nowego obszaru okna w strukturze komunikatu.
System użyje zmodyfikowanego obszaru do aktualizacji rozmiaru okna, wyśle aplikacji komunikat WM_SIZE, ten zostanie wyłapany przez SDL, przepakowany do formy zdarzenia SDL_WINDOWEVENT_RESIZED
i go zakolejkuje. I to wszystko — to takie proste.
Aby dekoracja zawsze była obsługiwana i renderowana prawidłowo, obszary muszą być przeliczane na nowo za każdym razem, gdy fizyczny rozmiar okna ulega zmianie. W takim przypadku SDL wysyła zdarzenie SDL_WINDOWEVENT_RESIZED
, w którym obszary są rekalkulowane. Dodatkowo, zawsze w takim przypadku aktualizowane są również minimalne rozmiary okna, dlatego że zmiana rozmiaru okna mogła zostać spowodowana zmianą proporcji pikseli lub widoczności paska stanu (o tym w dalszej części).
System w kilku przypadkach nie wykonuje kodu wątku aplikacji, a zamiast tego wykonuje tzw. modalną pętlę komunikatów, bez wiedzy aplikacji. Robi to w trakcie przeciągania okna lub jego rozciągania za pomocą myszy, a także gdy pokazywane jest systemowe menu (np. skrótem Alt+Space). W tym czasie pętla gry wisi, a więc nie tylko logika gry nie jest aktualizowana, ale i okno nie jest przemalowywane.
Przemalowywanie okna w trakcie rozciągania myszą można zaimplementować na wiele sposobów, ale że renderowanie może być wykonywane tylko i wyłącznie w głównym wątku aplikacji, paleta rozwiązań kurczy się do jednego — hook na zdarzenia SDL-a. Wystarczy zarejestrować tzw. event watcher za pomocą funkcji SDL_AddEventWatch i od tej pory SDL będzie wywoływał ten callback zawsze po przepakowaniu komunikatu do formy zdarzenia.
Nam zależy na tym, aby wyłapać moment zakolejkowania zdarzenia zmiany rozmiaru okna, więc w callbacku watchera wyłapuje się zdarzenie SDL_WINDOWEVENT_RESIZED
i przemalowuje okno. Jest to bezpieczne, bo callback działa w ramach głównego wątku aplikacji, a więc przemalowywanie okna nie musi być w żaden sposób synchronizowane.
Jedna ważna rzecz — renderowanie klatki gry to najbardziej czasochłonna czynność, więc odmalowywanie okna maksymalnie zopymalizowałem. Logika gry wisi w trakcie pętli modalnej, więc nie ma sensu renderować całej klatki od podstaw. Biorę po prostu bieżącą klatkę (jest wyrenderowana na teksturze tylnego bufora) i jedyne co robię to renderuję dekorację oraz tę bieżącą klatkę w oknie. W przyszłości na tej gotowej klatce będzie też wykonywany post-processing (shadery), ale na razie nie mam ich zaimplementowanych. Dodatkowo, aby przyspieszyć przemalowywanie okna, na tę chwilę dezaktywuję VSync. I to tyle — rozciąganie okna jest płynne, nawet setki zmian na sekundę.
Tylko niektóre są wykonywane automatycznie przez system — przeciąganie i rozciąganie okna, zmiana kształtu systemowego kursora oraz dwuklik na belkę do maksymalizacji/przywrócenia okna. Wszystko inne, czyli obsługa imitacji systemowych przycisków, a także systemowe menu (prawoklik na belce lub Alt+Space) muszą być obsłużone w kodzie aplikacji (silnika).
Wiemy gdzie znajdują się wszystkie systemowe przyciski, bo przechowujemy ich obszary w obiekcie okna. Tak więc aby móc podświetlać te przyciski i wykonywać ich akcje po kliknięciu, wystarczy bazować na zdarzeniach SDL dotyczących ruchu kursora w obrębie okna oraz wykrywać klikanie LPM i PPM. Na podstawie ruchu kursora aktywuję i dezaktywuję obiekt animacji przycisków, natomiast wciskanie przycisków zapamiętuję i po ich puszczeniu wykrywam pełne kliknięcie. Jeśli kliknięto w przycisk systemowy, to wykonuję jego akcję — nic nadzwyczajnego.
Kliknięcie PPM rejestrowane jest tak samo jak w przypadku systemowej dekoracji, czyli w dowolnym miejscu belki okna (również w obszarze ikonki aplikacji oraz w obszarach przycisków na belce). Po jego wykryciu pokazuję systemowe menu za pomocą funkcji SDL_ShowWindowSystemMenu, w miejscu kursora. Wychwytuję też zdarzenia wciśnięcia klawiszy i jeśli wykryję skrót Alt+Space, tą samą funkcją wyświetlam to menu w lewym górnym rogu okna. Kliknięcie LPM w ikonkę aplikacji na pasku tytułowym też pokazuje systemowe menu, ale tym razem wyrównane do ikonki (czyli pod nią).
Oczywiście obsługiwanych zdarzeń oraz wykonywanych czynności jest o wiele więcej, bo trzeba też resetować różne flagi czy obiekty animacji, gdy okno traci fokus lub input myszy, ale to pomijam, bo tego jest za dużo na jeden wpis blogowy.
Silnik zaprogramowałem tak, aby gdy okno jest wyświetlone jako mniejsze niż ekran i z dekoracją (tryb okienkowy), pokazanie lub ukrycie paska stanu zwiększa lub zmniejsza wysokość okna. Dzięki temu obszar klatki gry na ekranie ani drgnie — będzie to przydatne, w razie gdyby ktoś chciał nagrywać okno albo coś.
Podobnie dzieje się w przypadku zmiany proporcji pikseli — okno jest rozszerzane lub zwężane. Jednak w tym przypadku silnik najpierw sprawdza, czy okno posiadało rozmiar dopasowany do proporcji klatki gry i jeśli nie, to nie zmienia rozmiaru okna. Skoro użytkownik ustawił swój rozmiar okna, to lepiej się mu nie wtrącać. Okno można w dowolnym momencie zmusić do dopasowania, łapiąc za krawędź i trzymając Ctrl.
W przypadku gdy okno jest pełnoekranowe (zmaksymalizowane lub w fullscreenie), rozmiar okna nie może być zmieniany, więc jedynie rekalkuluje się obszary elementów okna i zapamiętuje żądanie zmiany widoczności paska stanu lub proporcji pikseli. Po co? A po to, że po wyłączeniu fullscreenu/maksymalizacji, rozmiar okna zostanie wtedy zaktualizowany, zgodnie z tym to zostało zmienione gdy okno było pełnoekranowe. Ot zmiana rozmiaru okna jest odraczana, gdy dotyczy okna pełnoekranowego. Trochę kodu trzeba naklepać, ale uzyskałem taki efekt, na jakim mi zależało.
W stylach pełnoekranowych, na ekranie powinna być widoczna wyłącznie klatka gry, tak więc dekoracja powinna być obsługiwana i renderowana tylko w trybie okienkowym oraz zmaksymalizowanym. Mało tego — w trybie zmaksymalizowanym, callback do hit-testowania nie może umożliwiać przesuwania i rozciągania okna. Tak więc w trakcie aktywacji fullscreenu, callback ten powinien być wyrejestrowany.
Tutaj, jak pisałem na początku, mamy pełną swobodę. Renderowanie zawartości okna realizowane jest obecnie w formie łańcuszka (rendering chain), wykonując wiele kroków, na wielu teksturach tylnych buforów:
336×240
pikseli, za pomocą software'owego ray-tracingu (jeszcze nie zaimplementowane),A następnie renderowanie na płótnie okna:
Renderowania zawartości paska stanu nie implementowałem, bo nie mam jeszcze importów funkcji SDL-a do formatowania stringów, a poza tym nie wiem jaka będzie ostateczna forma tych danych, więc nie poświęcałem na to czasu, bo i tak bym ten kod usunął. Zresztą po zbudowaniu demówki, cały kod renderowania wywaliłem i mam puste, czarne okno.
Wiele gier renderuje obraz klatki z zachowaniem konkretnych proporcji klatki, szczególnie gry w stylu retro. Ponieważ rozdzielczości monitorów są różne, po bokach lub u góry i dołu ekranu zostaje nieużywana przestrzeń (z reguły cienkie pasy). Zwykle wypełnia się je na czarno, ale niektóre gry pozwalają wypełnić tę przestrzeń w formie np. kafli z powtarzającym się wzorkiem lub logo.
Mój silnik robi dokładnie to samo — może renderować ”coś” w tych pustych paskach. W demówce użyłem bieżącej klatki, rozpikselowanej i przyciemnionej, aby aktualny obszar klatki był wyróżniony. Docelowo najpewniej skorzystam z tego efektu, ale możliwe, że zamiast pikselozy, użyję rozmycia. Sprawdzałem to, użyłem bieżącej klatki, ale ustawiłem skalowanie liniowe zamiast nearest
— efekt podobny do szklanej belki, znanej z motywu Aero systemu Windows Vista/7, całkiem fajny.
Różnica pomiędzy moim silnikiem a ww. grami (pozwalającymi coś renderować w pustej przestrzeni klienta) jest taka, że gdy gra jest w małym okienku lub jest zmaksymalizowana, za tę pustą przestrzeń uznaje się też obszar belki okna i paska stanu, dzięki czemu wyrenderowane wypełnienie dodatkowo staje się tłem elementów dekoracji. Natomiast w trybie pełnoekranowym, dekoracja nie jest używana, więc wypełnienie dotyczy tylko nieużywanych obszarów okna, wynikających z różnych proporcji okna i klatki gry.
Tutaj porównanie dekoracji własnej (z własną liczbą kontrolek i własnym paskiem stanu) oraz tej systemowej, której belka tytułowa oraz pasek stanu renderowane są przez system, zgodnie z aktualnie ustawionym motywem:
No nie trzeba być geniuszem, aby wywnioskować, że własna dekoracja to strzał w dziesiątkę.
Tak jak poprzednie demówki, tak i ta nie zapisuje niczego na dysku. Po uruchomieniu, pokaże się konsola z logami oraz okno gry. Klatka gry jest animowana (origin przesuwa się zgodnie z różą polarną), aby było widać, że silnik pracuje (i kiedy nie, czyli w trakcie modalnych pętli komunikatów). Można sobie poklikać, porozciągać itd. — to tylko showcase.
Klawiszologia:
Jeśli chodzi o systemowe przyciski, te na belce okna, ten pierwszy z lewej służy do aktywacji renderowania tła okna w formie ciemniejszej i rozpikselowanej, natomiast ten drugi służy do pokazania/ukrycia paska stanu. Przeznaczenie pozostałych trzech z prawej raczej powinno być wam znane.
W razie czego, w katalogu demówki znajduje się plik background.png
i jeśli ktoś chce się pobawić, to możecie do niego wkleić własny obrazek (reboot demówki wymagany). Trzymajcie się oryginalnego rozmiaru tej grafiki.
Zostało mi jeszcze kilka pierdółek do zrobienia, aby lepiej sobie źródła przygotować do dalszego rzeźbienia. Jedną z nich jest zmiana stylu prefiksów dla identyfikatorów API silnika — zamieniam prefiksy z Game_
na Wnc_
(API silnika), Game_SDL_
na SDL_
(API SDL-a) oraz Game_Win_
na Win_
(dla Win32 API). Zajmie mi to pewnie 15 minut, bo Lazarus ma różne fajne narzędzia do edycji tekstu.
Czas w końcu ruszyć dupę i zacząć klepać kernel obsługujący mapy — do końca tego roku musi być gotowy.
TL;DR: demówka do testowania nowego UI jest tutaj — WtNC — UI kernel.zip
Chciałem ten wpis napisać w Sylwestra, ale postanowiłem nie spieszyć się i zadbać o to, aby kod silnika był dopięty na ostatni guzik. Tak więc chciałbym się pochwalić, że kod silnika jest ukończony — prace nad nim trwały około 30 miesięcy i w tym czasie powstał framework, na podstawie którego teraz budować będę właściwą grę. Kilka ogólnych statystyk dotyczących silnika:
62
moduły pascalowe (pliki *.pp
oraz jeden *.lpr
) — z implementacją obiektów oraz podsystemów,127
plików dołączanych (pliki .inc
) — ze stałymi oraz wydzielonymi funkcjami z modułów, dla zwiększenia czytelności,44,385
linijki kodu (łącznie),40.54%
linijek to komentarze dokumentacji.Do tego trzy edytory zawartości, zbudowane jako standardowe aplikacje okienkowe, używające biblioteki LCL:
3,593
linijki kodu,9,143
linijki kodu,8,763
linijki kodu.oraz pakiet własnych komponentów używanych do budowy tych edytorów (obecnie jeden komponent) — 822
linie kodu. Do ułatwienia sobie pracy z silnikiem, stworzyłem też pakiet z szablonami plików (szablon pliku z pustym modułem, ze strukturą, z bazową kontrolką UI oraz pusty plik dołączany), instalowany jako rozszerzenie Lazarusa — 713
linii kodu.
Summa summarum, w dwa i pół roku naklepałem 67,585
linijek kodu, w 277
plikach, łącznie ponad dwa i pół miliona znaków. To jest aktualny stan źródeł, a więc bez liczenia kodu, który został napisany i później usunięty (wszelkie tymczasowe testy, demówki itd.). Statystyki te obliczone zostały za pomocą narzędzia Universal Code Lines Counter.
I to by było na tyle, jeśli chodzi o statystyki — teraz czas na przyjemniejszą rzecz.
W ostatnich tygodniach pracowałem nad rozbudową kernela UI, tak aby zapewnić wsparcie wpisywania tekstu z klawiatury do kontrolek. Taka możliwość jest mi potrzebna z dwóch powodów. Po pierwsze dlatego, że gra będzie zapewniać tworzenie profili graczy, i gracz w trakcie tworzenia profilu będzie mógł wpisać dowolną jego nazwę (lub ją zmienić). A po drugie dlatego, że zamierzam zaimplementować w silniku terminal debugowania, który pozwoli mi w dowolnym momencie wpisać specjalne komendy i at runtime modyfikować parametry silnika, mapy itd. Taki terminal będzie zbudowany z tego samego kontenera UI i kontrolek co reszta interfejsu gry.
Do reprezentacji edytora tekstu stworzyłem wirtualny obiekt, imitujący klasyczne pole edycyjne. Wirtualny, bo jego zadaniem jest jedynie kolekcjonowanie danych tekstowych dostarczanych w zdarzeniach SDL-a, emitowanych w trakcie trybu text input mode, a także obsługa karetki (czyli kursora tekstowego) i bloku zaznaczenia tekstu. Jego struktura jest relatywnie prosta:
type
// The data structure of a text editor context.
TGame_Edit = struct void
Data: array [0 .. GAME_EDIT_BUFFER_SIZE - 1] of TGame_Char; // A buffer to store the entered text.
Terminator: PGame_Char; // A pointer to the text sentinel, should always point to a valid null terminator.
Caret: PGame_Char; // A pointer to the carret.
CaretStart: PGame_Char; // A pointer to the code point from which the selection block begins.
CaretBlink: TGame_Steps; // Step counter for the caret blinking animation.
Size: TGame_SInt32; // The size of the entered text, in bytes.
Length: TGame_SInt32; // The length of the entered text, in UTF-8 code points.
LengthLimit: TGame_SInt32; // The maximum allowed length of the text, in UTF-8 code points.
Filter: TGame_EditCallbackFilter; // A pointer to a function that filters the incomming data.
Active: TGame_Bool8; // A flag indicating whether the editor is currently active.
ChangedText: TGame_Bool8; // A flag indicating whether the text was changed during update.
ChangedCaret: TGame_Bool8; // A flag indicating whether the caret position has changed during update.
end;
Logika tego obiektu zaimplementowana jest w formie kilkudziesięciu funkcji, które są używane w trakcie aktualizacji obiektu na podstawie zdarzeń SDL-a. Te funkcje mogą też być używane ręcznie, z poziomu logiki gry, np. aby móc manualnie modyfikować pozycję kursora, wstawiać tekst czy go zaznaczać. Jest ich dość sporo, więc zamiast listy, wrzucę całościowy ich zrzut:
Aby kontrolka mogła obsługiwać wpisywanie tekstu, należy w jej strukturze zadeklarować pole będą obiektem wyżej opisanego kontekstu edytora. Kernel UI, w trakcie aktualizacji, na podstawie inputu z urządzeń wejścia sprawdza czy wciśnięto LPM nad kontrolką lub klawisz/przycisk akceptacji i jeśli tak, to wysyła specjalne zdarzenie do kontrolki z pytaniem o to, czy obsługuje tryb edycji tekstu. Jeśli kontrolka go obsługuje, to odpowiada pozytywnie, UI box zapala sobie flage i wysyła kontrolce zdarzenie rozpoczęcia edycji tekstu. Kontrolka aktywuje swój obiekt edytora i przekazuje go do podsystemu edycji, w którym będzie otrzymywać zdarzenia SDL-a.
W przypadku akceptacji lub odrzucenia zmian w tekście, albo gdy UI box musi odebrać kontrolce przechwytywanie tekstu, aktualizator UI box wysyła kontrolce odpowiednie zdarzenie i kontrolka dezaktywuje obiekt edytora. To jest dość proste. W skrócie — UI box negocjuje rozpoczęcie przechwytywania edycji z kontrolką oraz zapewnia dostarczanie kontrolce odpowiednich zdarzeń, a kontrolka odpowiedzialna jest za aktywację i dezaktywację jej obiektu edytora.
Wcześniej, gdy jeszcze nie było kontrolek do wpisywania tekstu, aktualizowanie UI box było łatwiejsze. Jedyną istotną informacją w obsłudze kontrolek był fokus, co oznaczało, że dana kontrolka jest po prostu aktywna. UI box w swojej strukturze trzymał pointer na sfokusowaną kontrolkę i na tej podstawie wysyłał zdarzenia. W przypadku ruchu kursora myszy lub wciskania klawiszy/przycisków nawigacji, fokus był przenoszony na inną kontrolkę (lub na żadną, np. gdy kursor znajdował się nad pustym obszarem).
Wprowadzenie kontrolek do edytowania tekstu wymusiło dodanie drugiego parametru — hovera. Fokus nadal jest kluczową i jedyną informacją o tym, która kontrolka jest aktualnie aktywna i dostaje input z urządzeń wejścia, natomiast hover jest dodatkowym stanem, który informuje kontrolkę o tym, że kursor myszy znajduje się w jej obszarze.
I teraz najważniejsze — jeśli żadna kontrolka nie posiada aktywnego edytora tekstu i kursor myszy jest ukryty, wciskanie triggerów nawigacji (klawisze, przyciski joysticków) standardowo przenosi fokus pomiędzy kontrolkami. Jeśli kursor jest widoczny, poruszanie nim przenosi fokus i hover na kontrolkę, która się pod nim znajduje. Natomiast jeśli któraś kontrolka ma aktywny edytor (trwa przechwytywanie edycji), fokus jest zawłaszczony przez tę kontrolkę, a ruch myszy dostarcza innym kontrolkom tylko zdarzenia dotyczące obecności kursora w ich obszarze (tak samo jak w Windows).
Tak więc dopóki trwa edycja tekstu, aktualizator UI box nie modyfikuje fokusu. Tryb edycji może być zakończony na wiele sposobów:
Kernel UI zapewnia wsparcie przechwytywania myszy (tzw. mouse capture), co oznacza, że wciśnięcie LPM nad kontrolką może ten tryb uruchomić (jeśli kontrolka zgodzi się na niego) i od tego momentu — póki trzyma się LPM — można przesuwać kursor gdziekolwiek, nawet i poza okno gry. W trakcie przechwytywania myszy, hover i fokus nie są modyfikowane, a input z klawiatury i joysticków jest ignorowany (kółka myszy również). Przechwytywanie myszy można zakończyć albo standardowo, czyli puszczając LPM, ale też można go anulować, wciskając w międzyczasie PPM lub klawisz Esc. Anulowanie przechwytywania powoduje wysłanie do kontrolki specjalnego zdarzenia, co pozwala kontrolce cofnąć wprowadzone w niej zmiany (puszczenie LPM wysyła zdarzenie pozwalające jej zmiany zaakceptować). To jest dodatkowy ficzer, zainspirowany zachowaniem powłoki Windows.
Tak więc UI box przewiduje dwa tryby przechwytywania — edycji tekstu oraz myszy. Za każdym razem, gdy wciśnie się LPM nad kontrolką, aktualizator UI box pyta kontrolkę o to czy obsługuje te tryby i jeśli tak, to je aktywuje. Kontrolka może nie obsługiwać żadnego trybu przechwytywania (np. zwykły przycisk czy etykieta), może obsługiwać jeden (np. slider czy scrollbar powinien przechwytywać mysz), ale może też obsługiwać oba (np. pole edycyjne, które przechwytuje edycję tekstu, ale też mysz w trakcie zaznaczania tekstu za pomocą właśnie myszy). O przechwytywanie edycji tekstu kontrolka pytana jest najpierw (edycja ma wyższy priorytet), dzięki czemu wciśnięcie LPM nad polem edycyjnym pozwala aktywować edycję tekstu oraz jednocześnie zaznaczać tekst myszą — dokładnie tak samo jak w polach edycyjnych Windows. Jest to znane zachowanie, zapisane w pamięci mięśniowej, więc UI powinno być z nim zgodne.
Ogólnie cały ten sposób obsługi UI, kontrolek do wpisywania tekstu oraz ich aktualizowania może się wydawać nietypowy — w końcu działa inaczej niż np. powłoka Windows. W niej wysyłane są komunikaty do procedur okien, przy czym każda fokusowalna (interaktywna) kontrolka posiada własną procedurę okna. Systemowe UI zbudowane jest w formie drzewiastej, a więc kontrolki mogą być osadzane jedna w drugiej. Komunikat dostarczany jest do procedury okna, a następnie podawany jest do procedury okna-kontrolki, która go przetwarza. Ta może go przesłać do kolejnej kontrolki, i kolejnej itd.
UI, które zaimplementowałem w swoim silniku posiada płaską strukturę. Głównie chodzi o to, że osadzanie kontrolek nie będzie miało zastosowania w mojej grze, więc nie było sensu implementowania takiej drzewiastej struktury, ze względu na wyższy stopień jej złożoności.
Po drugie, zdarzenia SDL-a są w całości przetwarzane na początku każdej klatki gry, zanim zostanie wywołana główna funkcja aktualizacji logiki gry. Najpierw aktualizowane są na ich podstawie wszystkie podsystemy silnika (okno, mysz, klawiatura, joysticki, mapper, edycja tekstu itd.), a po przetworzeniu wszystkich zdarzeń, przechodzi się do aktualizacji logiki. Ponieważ UI box aktualizowany jest właśnie w kodzie logiki gry, kolejka zdarzeń SDL-a jest już przetworzona (a więc pusta). Aby móc swobodnie aktualizować obiekt edytora kontrolki UI, obiekt ten rejestruje się w podsystemie edycji i od teraz w każdej klatce gdy otrzymuje zdarzenia, w ramach tego wstępnego procesu przetwarzania zdarzeń. Kiedy przechodzi się do aktualizacji logiki gry, wywoływana jest funkcja aktualizacji UI box i kontrolka z edytorem tekstu ma już zaktualizowany obiekt edytora, a więc i najświeższe dane, więc aktualizator UI skupia się jedynie na hoverze i fokusie (dużo mniej roboty).
Na potrzeby testowania UI boxów, kontrolek oraz komunikacji pomiędzy aktualizatorem UI a kontrolkami, przygotowałem bardzo prosty program testowy. Jest w nim jeden UI box, w którym znajdują się cztery typy kontrolek:
Dodatkowo, te dwie w prawym górnym rogu są przypięte do ekranu. Klawiszologia:
Obszar UI box renderowany jest szarym kolorem i wyznacza obszar, w którym aktualizator UI wykrywa obecność kursora. Jeśli kontrolka jest poza tym obszarem, nie będzie hoverowana (po najechaniu kursorem nie podświetli się). Dostępne jest też scrollowanie za pomocą myszy — UI box będzie odpowiednio przesuwany w górę/dół. Demówka zapewnia trzy typy scrollowania:
Te dwie kwadratowe kontrolki w prawym górnym rogu są przypięte, co oznacza, że nie reagują na scrollowanie. To służy do testów hovera (żółta kontrolka udaje przezroczystą), a docelowo będzie służyło do obsługi scrollbara, który jak wiadomo musi mieć stałą pozycję na ekranie.
UI może być obsługiwane dowolnym urządzeniem wejścia. Może być obsługiwany myszą i wtedy fokusowana jest kontrolka pod kursorem. Kontrolki czerwone obsługują zdarzenia obrotu kółka myszy oraz nawigację na boki, więc jeśli umieści się nad taką kursor i obróci się kółko myszy, UI box nie zostanie przewinięty. UI może być też obsługiwane klawiaturą i dowolnym joystickiem — D-Pad oraz lewy analog pozwala na przenoszenie fokusu, cztery pierwsze przyciski służą do akceptacji i odrzucania (dwa są zduplikowane). Ruch myszy automatycznie pokazuje kursor, wciskanie klawiszy strzałek, D-Pada lub wychylanie lewego analoga automatycznie ukrywa kursor.
Ogólnie ten kernel UI jest tak złożonym systemem i ma tak wiele ficzerów, że słowne ich omówienie zajęłoby mi z pięć godzin, a ich napisanie wymagało by z dziesięciu długich wpisów blogowych, więc tutaj poprzestanę na wymienianiu ficzerów.
Głównym celem tej demówki jest sprawdzenie komunikacji aktualizatora UI z kontrolkami, a więc podglądanie wysyłania zdarzeń i ich kolejkowania, które następnie przekazywane są do callbacku obsługującego zdarzenia UI (na poziomie logiki gry). Demówka otwiera okno systemowej konsoli i wyświetla w niej logi. Do tych logów trafia cała transmisja zdarzeń dotyczących UI, więc można sobie podglądać jakie zdarzenia są emitowane wewnętrznie oraz które z nich trafiają do funkcji callbacku (te mają dodatkowe wcięcie i prefiks Callback
).
Renderowanie kontrolek jest minimalne, bardzo uproszczone, dlatego że nie było przedmiotem testów. Kontrolki są prostokątami (choć wspierają nieregularne kształty) o jednolitym kolorze: gdy są odblokowane to są kolorowe, gdy się je zablokuje to w skali szarości. Kontrolka z fokusem jest rozjaśniana, a niebieskie kontrolki, imitujące pola edycyjne, posiadają dodatkową, białą ramkę, gdy mają aktywny obiekt edytora.
Gdy trwa wpisywanie tekstu, można hoverować inne kontrolki, ale ten stan nie jest renderowany w specjalny sposób (w konsoli zobaczyć można zdarzenia myszy i zmienia się kursor). Wpisywany tekst do niebieskich kontrolek nie jest nijak renderowany — to będzie zaimplementowane dopiero gdy będę pisał właściwy kod takich kontrolek. Ale można w konsoli zobaczyć logowane zdarzenia zmiany tekstu (logowana jest nowa treść) oraz przesuwania karetki (logowana jest jej nowa pozycja). Odrzucenie tekstu edytora nie jest oprogramowane, więc nowa treść i tak jest aplikowana.
Scrollowanie UI box też jest prymitywne, bez animacji, bo nie było to przedmiotem testów. Możliwe jest przewinięcie UI box w taki sposób, że w całości zniknie poza okno i już się go nie przywróci, więc tym się nie przejmujcie.
Demówka nie tworzy żadnych plików na dysku — jedynie odczytuje dane ze swoich plików, a wszelkie logi trafiają do konsoli. We właściwym projekcie logi będą trafiać do pliku logu w katalogu ustawień użytkownika, chyba że odpali się grę z parametrem pozwalającym logować informacje tylko w konsoli (czyli tak jak w tej demówce).
Ok, to tyle — miłej zabawy.
@Boski: pomysł był pierwszy (czyli gatunek, styl rozgrywki, ogólna fabuła, szata graficzna, docelowa grupa odbiorców itd.), dopiero mniej więcej rok później zabrałem się za implementację, czyli na początek za dobranie paradygmatu, określeniu stylu API, a następnie za pisanie kodu silnika.
Będzie to gra typu action adventure z pixel artową szatą graficzną (w stylu np. Zelda ALttP), przeznaczona dla jednego gracza (single player) oraz z trybem kanapowej kooperacji dla dwóch graczy (local coop). Jeśli chodzi o technikalia, to powstaje we Free Pascalu przy użyciu biblioteki SDL3 (m.in. aby mieć dostęp do GPU, mikser dźwięków), będzie wykorzystywać software'owy raytracing aby uzyskać ciekawe efekty wizualne (wykonywany wielowątkowo na CPU), który będzie możliwy do konfigurowania (tak aby zapewnić płynne działanie na starych pecetach). Klimat retro, atmosfera będzie połączeniem grozy i humoru na poziomie gimnazjum/memów, sprajty postaci i ich zachowania inspirowane będą grami na NES-a od firmy Technōs. Ze względu na specyficzny humor i oprawę graficzną, przeznaczona dla ludzi młodych i tych z poczuciem humoru. To tak w skrócie. ;)
Muszę jeszcze mały refactoring zrobić i kilka rzeczy zmienić, aby sobie lepiej przygotować źródła do dalszego rzeźbienia. Napiszę o tym osobny wpis w niedalekiej przyszłości, bo jeden cukierek się pojawi.
Zapewne wielu z was widziało materiał od Casey Muratori zwany "Clean" Code, Horrible Performance traktujący o tym, jak wydajność oprogramowania cierpi przez przyjęte zasady wytwarzania ”czystego kodu”. O ile to jak najbardziej prawda, co zresztą Casey ładnie w tym materiale wykazał, o tyle jest z tym jeden zasadniczy problem — skalowalność.
Tego typu optymalizacje wspaniale prezentują się na przykładzie bzdurnych figur geometrycznych, natomiast kiedy przychodzi czas na ich zastosowanie w sensownym, szczególnie dużym projekcie, to już na samą myśl chce się rwać włosy z głowy.
Jeśli potrzebuję prostych struktur danych, których zawartość nie jest dziedziczona, korzystam ze zwykłych rekordów. Jeśli taka struktura nie jest krytyczną, a do dowolnego użytku (np. punkt, obszar, wierzchołek itp.), czyli jej zawartość może być swobodnie modyfikowana, pola struktury są widoczne.
Natomiast jeśli struktura zawiera w sobie elementy, które muszą być w jakiś sposób zarządzane (np. osadzone listy, obiekty stanu itd.) lub służą wyłącznie na wewnętrzny użytek struktury, cała struktura deklarowana jest jako nieprzezroczysta. Ot operator odniesienia pokazuje pustą listę składowych i nie ma do nich dostępu spoza modułu, w którym jest zadeklarowana.
Aby więc móc z niej odczytywać dane i je modyfikować, dla każdej takiej nieprzezroczystej struktury przygotowany jest zestaw funkcji. Zestaw ten daje dostęp tylko do tych danych, do których dostęp powinien być zapewniony, natomiast do pozostałych dostępu nie ma. Ot zwykła enkapsulacja, tyle że struktur zamiast klas.
Drugim typem struktur danych są te, które mogą być dziedziczone. Ponieważ pascalowe rekordy nie obsługują dziedziczenia, używam do tego celu klasycznych obiektów, wprowadzonych do języka Turbo Pascal, jako pierwszy krok Borlanda w kierunku implementacji nowoczesnej na tamte czasy obiektowości. Mimo, że takie obiekty wspierają niemal wszystkie ficzery OOP (oprócz interfejsów), na fundamentalnym poziomie są binarnie zgodne z rekordami — zajmują w pamięci tyle ile suma rozmiaru wszystkich pól, a jeśli nie używa się metod wirtualnych, VMT w ogóle nie jest alokowany. Tak więc stare dobre obiekty mogą być używane dokładnie tak samo jak proste struktury danych, ale w gratisie ma się dostęp do dziedziczenia.
Casey w ww. materiale złamał wszystkie zasady czystego kodu, aby zyskać maksymalną wydajność. Pozbył się klas, a więc VMT (wirtualnych metod), zapewnił bezpośredni dostęp do internalsów struktur, a nawet przeniósł podobne dane do lookup-tablic. Ile można było uprościć, tyle uprościł i przyspieszył działanie kodu ~dwudziestokrotnie — nieźle.
Mam więc w silniku bazowy typ kontrolki UI — nazwijmy ją UiControl
. Jest to klasyczny, turbo-pascalowy obiekt, zawierający kilka pól. Na podstawie tego bazowego typu mogę stworzyć konkretny typ kontrolki (np. UiButton
), odziedziczyć dane z typu bazowego oraz dodać te specyficzne dla przycisku (np. Caption
). Taki typ przycisku mogę ponownie rozszerzyć i stworzyć np. UiGlyphButton
, czyli przycisk z obrazkiem obok tekstu.
I tutaj pojawia się problem — jak z jednej strony pominąć VMT, a jednocześnie zapewnić polimorfizm? Ten jest absolutnie konieczny, dlatego że kontener UI, w którym osadzone są kontrolki, nie ma bladego pojęcia o tym czym konkretnie dana kontrolka jest i jak ma ją obsłużyć, a potrzebuje ją przecież aktualizować, zarówno na podstawie inputu użytkownika, jak i popchnąć do przodu jej animacje (w każdej klatce gry).
Skoro nie mamy VMT ani żadnych wskaźników na funkcje bazowe, możemy zrobić to tak, jak sugerują zwolennicy ”brudnego kodu”, czyli na podstawie typu kontrolki (enum lub liczba) zapisać sobie instrukcję wyboru i w niej wywołać docelową funkcję, przeznaczoną stricte dla danego typu kontrolki. Mniej więcej w ten sposób:
type
PUiControl = ^TUiControl;
TUiControl = object
Type_: Integer; // typ kontrolki
// tutaj pola z różnymi danymi
end;
procedure UiControlUpdate (AControl: PUiControl);
begin
case AControl^.Type_ of
UI_BUTTON: UiButtonUpdate(AControl);
UI_SLIDER: UiSliderUpdate(AControl);
{..}
end;
end;
Wszystko fajnie — skoro kontener UI nie wie jakiego typu jest kontrolka, ot wywołuje UiControlUpdate
i ta ją rozróżnia, a następnie aktualizuje konkretną funkcją. Aby więc zyskać polimorfizm, wystarczy postąpić odwrotnie niż w przypadku typowego OOP — klasa bazowa ma informacje o typie końcowym (skonkretyzowanym).
Ale tutaj zamiast wywołania jednej funkcji, mamy dwie, a także narzut związany z instrukcją wyboru. Można by pójść dalej i zamiast wywoływać funkcje w tej instrukcji wyboru, przenieść kod z funkcji UiButtonUpdate
i UiSliderUpdate
do wnętrza funkcji UiControlUpdate
— wtedy będziemy mieli tylko jedno wywołanie. Tak zrobił Casey w przypadku jego figur.
Problem jednak polega na tym, że docelowych, skonkretyzowanych typów kontrolek będzie dużo (obstawiam, że koło 20-30). Mało tego, kod aktualizujący każdą z tych kontrolek może być długi, to mogą być setki linijek. Tak więc upchnięcie całej logiki aktualizacji kontrolek w jednej funkcji z instrukcją posiadającą dziesiątki wyborów i wymagającą zapewne dziesiątek zmiennych lokalnych wyprodukowałoby funkcję UiControlUpdate
zawierającą tysiące linijek kodu. No słabo.
Problem drugi — Casey złamał również hermetyzację, aby z poziomu takiej funkcji mieć bezpośredni dostęp do danych każdego obiektu figury. Niedawno sprawdzałem takie podejście i się od niego odbiłem. Mając dziesiątki typów konkretnych kontrolek i dostęp do dziesiątek pól w każdej kontrolce, bałbym się cokolwiek z nimi robić, aby czegoś nie zepsuć lub nie stracić spójności ich danych. Tak więc bezpośrednia modyfikacja ich pól w giga-funkcji aktualizującej byłaby ryzykowna i trudna w utrzymaniu.
Problem trzeci — uwspólnianie logiki. Załóżmy więc, że pozostajemy przy instrukcji wyboru, w której wywoływana jest docelowa funkcja aktualizująca, np. przycisk. Aby wykonać bazowy kod aktualizacji oraz dodatkowy kod aktualizujący specyficzne dane przycisku, mamy dwa rozwiązania. Pierwsze to skopiowanie logiki bazowej funkcji aktualizującej do funkcji aktualizującej przycisk. Łamiemy więc regułę DRY, w efekcie mamy dokładnie ten sam kod w dwóch miejscach, w dodatku najpewniej w dwóch osobnych modułach (każdy typ kontrolki to osobny moduł).
W typowym OOP, czyli w przypadku klas, możemy bez problemu wywołać wirtualną metodę z klasy bazowej — robi się to za pomocą słówka inherited
. W razie chęci przekazania zmodyfikowanych parametrów do wywołania bazowego, możemy po tym słówku kluczowym napisać nazwę metody i podać jej parametry. Jeśli byśmy chcieli coś takiego zrobić w naszym przypadku, możemy to zrobić tak:
procedure UiButtonUpdate(AButton: PUiButton);
begin
// wywołanie funkcji aktualizującej typ bazowy
UiControlUpdate(AButton);
// tutaj aktualizacja danych specyficznych dla przycisku
end;
Wszystko fajnie, ale to nie zadziała — bazowa funkcja nie wykonuje żadnej aktualizacji, a jedynie wywołuje tę konkretną funkcję. To oczywiście prowadzi do nieskończonej pętli i stack overflow. Żeby móc coś takiego zrobić, trzeba by mieć dwie bazowe funkcje aktualizujące — jedna faktycznie aktualizuje dane typu bazowego, a druga jest przeznaczona wyłącznie na potrzeby polimorfizmu, czyli ta z instrukcją wyboru:
procedure UiControlUpdate(AControl: PUiControl);
begin
// tutaj aktualizacja danych zadeklarowanych w strukturze bazowej kontrolki
end;
procedure UiControlUpdatePoli(AControl: PUiControl);
begin
case AControl^.Type_ of
UI_BUTTON: UiButtonUpdate(AControl);
UI_SLIDER: UiSliderUpdate(AControl);
{..}
end;
end;
Teraz jeśli potrzebujemy zaktualizować kontrolkę w ramach polimorfizmu, korzystamy z dedykowanej funkcji. Np. kontener UI zawiera listę kontrolek, których typów docelowych nie zna, więc używamy funkcji przeznaczonej do polimorfizmu:
// dla każdej kontrolki osadzonej w kontenerze UI
for Control in UiContainer.Controls do
// wywołaj funkcję aktualizującą, bez znajomości typu docelowego
UiControlUpdatePoli(Control);
Natomiast aby z poziomu funkcji aktualizującej konkretny typ kontrolki — np. Button
— móc zaktualizować dane typu bazowego (odpowiednik inherited
z OOP), wywołujemy tę właściwą, zamiast duplikowania jej kodu:
procedure UiButtonUpdate(AButton: PUiButton);
begin
// wywołanie nie-polimorficznej funkcji aktualizującej typ bazowy
UiControlUpdate(AButton);
// tutaj aktualizacja danych specyficznych dla przycisku
end;
I wtedy będzie cacy. Tzn. nie będzie, dlatego że dla każdej takiej funkcji udającej metodę wirtualną, tak naprawdę musimy deklarować dwie osobne. W dodatku za każdym razem gdy tworzymy nowy typ skonkretyzowanej kontrolki, trzeba wracać do implementacji typu bazowego i go modyfikować. Gra nie warta świeczki, a to co opisałem wyżej to i tak nie wszystko, bo problemów może być więcej.
Jeśli dany obiekt potrzebuje funkcji, której zachowanie nie będzie redefiniowane w typach dziedziczących, to jest to zwykła funkcja. Natomiast aby mieć odpowiednik metod wirtualnych, ale bez VMT, skorzystałem ze starych dobrych pointerów na funkcje i jej bazowa wersja zadeklarowana jest w tym samym module co typ bazowy. Dla każdej funkcji, której zachowanie może być redefiniowane, w strukturze obiektu jest pointer — nazywam je top-order functions
, czyli funkcjami najwyższego rzędu:
type
PUiControl = ^TUiControl;
TUiControl = object
Update: procedure (AControl: PUiControl); // pointer na funkcję najwyższego rzędu
end;
// deklaracja funkcji jako widocznej z poziomu innych modułów
procedure UiControlUpdate (AControl: PUiControl);
W funkcji inicjalizującej obiekt kontrolki, ustawiam pointer na funkcję bazową:
procedure UiControlInitialize (AControl: PUiControl);
begin
AControl^.Update := @UiControlUpdate;
end;
A co z typami dziedziczącymi z UiControl
? To redefiniowane jest w ich funkcjach inicjalizujących. Niech za przykład posłuży UiButton
:
procedure UiButtonInitialize (AButton: PUiButton);
begin
// inicjalizacja bazowych danych kontrolki
UiControlInitialize(AButton);
// ustawienie właściwych przyciskowi wskaźników na funkcje, nadpisując bazowe
AButton^.Update := @UiButtonUpdate;
end;
W ten sposób można swobodnie nadpisywać funkcje najwyższego rzędu, nie być zmuszonym do tworzenia dwóch funkcji zamiast jednej, a także mieć polimorfizm. W dodatku, bezpośrednie wywołanie funkcji bazowej (tutaj: UiControlInitialize
w ciele UiButtonInitialize
) może być bez problemu zinline'owane.
Tak więc nawet jeśli droga dziedziczenia jest długa (np. UiControl
→ UiButton
→ UiGlyphButton
), wszystkie wywołania funkcji bazowych mogą zostać zinline'owane, a więc polimorficzne wywołanie funkcji aktualizującej może zostać zoptymalizowane przez kompilator do formy wywołania tylko jednej funkcji spod wskaźnika, co będzie wielokrotnie szybsze niż w przypadku klas i ich metod wirtualnych.
Przykład aktualizacji wszystkich kontrolek kontenera UI:
// dla każdej kontrolki osadzonej w kontenerze UI
for Control in UiContainer.Controls do
// wywołaj funkcję najwyższego rzędu spod wskaźnika z pola "TUiControl.Update"
Control^.Update(Control);
Casey elegancko pokazał jak łamiąc zasady czystego kodu, drastycznie zwiększyć wydajność kodu. Niestety wygląda to zachęcająco jedynie na prymitywnych przykładach, natomiast skorzystanie z takich technik w dużym projekcie, przerobiłoby kod na istne pole minowe — skomplikowane, niebezpieczne, trudne w utrzymaniu i czasochłonne.
Z reguły olewam przyjęte zasady, wybierając to co najlepiej pasuje do wymagań projektowych i moich preferencji. Chętnie słucham rad udzielanych przez tych, którzy stali się wrogami czystego kodu (bo to mniej lub bardziej słuszne), jednak staram się nie słuchać takich rad bezkrytycznie, bo można sobie zdewastować projekt, produkując zapas spaghetti na kilka lat.
To że nie musi posiadać metod wirtualnych jest wiadome. W przypadku tego wpisu kontekst jest jasny — jak zrobić odpowiednik metod wirtualnych dla prostych struktur danych, bez używania VMT i z minimalnym narzutem, w Pascalu. I to w sumie wszystko.
Dokończyłem implementowanie dodatkowych ficzerów dotyczących obsługi UI za pomocą myszy. Gdyby ktoś się zastanawiał nad tym ile przypadków trzeba obsłużyć, to niżej jest lista kroków koniecznych do wykonania, która opisuje najważniejsze aspekty. Lista ta dotyczy jedynie aktualizacji UI na podstawie myszy, a to dopiero pierwsza część z trzech — drugi krok to zmiana fokusu za pomocą klawiatury/joysticków, a trzeci to aktualizacja stanu UI (akceptacja lub odrzucenie).
Bezwarunkowo:
- Jeśli UI box nie jest przeznaczony do obsługi myszą lub gracz nie ma do tego uprawnień:
- Wyjdź przerywając "mouse update", ale nie cały "update".
- Jeśli trwa rolling:
- Wyjdź przerywając "mouse update", ale nie cały "update".
- Jeśli okno ma input myszy, kursor poruszył się i kursor nie jest widoczny:
- Ustaw flagę widoczności kursora.
- Zresetuj flagę przechwytywania scrollowania przez UI box.
- Określ kontrolkę znajdującą się pod kursorem.
- Jeśli kursor znajduje się nad którąś kontrolką:
- Wyślij zdarzenie "internal mouse cursor" do sfokusowanej kontrolki.
- Ustaw nowy kształt kursora.
- Jeśli kursor nie jest w pełni widoczny:
- Wyjdź przerywając "mouse update", ale nie cały "update".
- Zresetuj dane fokusu poprzedniej kontrolki w UI box.
Przypadek 1. - okno straciło input myszy:
- Jeśli mouse capture jest aktywny:
- Wyślij zdarzenie "internal mouse cancel" do przechwytywanej kontrolki.
- Wyłącz mouse capture w UI box.
- Jeśli kontrolka jest sfokusowana:
- Wyślij zdarzenie "common focus out" do przechwytywanej kontrolki.
- Zresetuj dane fokusu w UI box.
- Zresetuj dane ostatnio klikniętej kontrolki.
- Ustaw domyślny kształt kursora.
- Wyjdź przerywając "mouse update", ale nie cały "update".
Przypadek 2. - aktywny mouse capture:
- Jeśli przechwytywana kontrolka jest widoczna i odblokowana:
| - Jeśli mouse capture zakończył się:
| | - Wyłącz mouse capture w UI box.
| | - Jeśli mouse capture został standardowo zakończony:
| | | - Wyślij zdarzenie "internal mouse up" do sfokusowanej kontrolki.
| | - ...w przeciwnym razie (mouse capture został anulowany):
| | - Wyślij zdarzenie "internal mouse cancel" do sfokusowanej kontrolki.
| | - Określ kontrolkę znajdującą się pod kursorem.
| | - Jeśli kursor nie znajduje się nad żadną kontrolką:
| | | - Wyślij zdarzenie "common focus out" do sfokusowanej kontrolki.
| | | - Zresetuj dane fokusu w UI box.
| | | - Zresetuj dane ostatnio klikniętej kontrolki.
| | | - Ustaw domyślny kształt kursora.
| | - ...w przeciwnym razie (kursor znajduje się nad którąś kontrolką):
| | - Jeśli kursor nadal znajduje się nad sfokusowaną kontrolką:
| | | - Jeśli kursor poruszył się:
| | | - Wyślij zdarzenie "internal mouse move" do sfokusowanej kontrolki.
| | | - Wyślij zdarzenie "internal mouse cursor" do sfokusowanej kontrolki.
| | | - Ustaw nowy kształt kursora.
| | - ...w przeciwnym razie (kursor znajduje się nad inną kontrolką):
| | - Wyślij zdarzenie "common focus out" do sfokusowanej kontrolki.
| | - Jeśli kontrolka jest odblokowana:
| | - Ustaw kontrolkę jako sfokusowaną w UI box.
| | - Wyślij zdarzenie "common focus in" do sfokusowanej kontrolki.
| | - Wyślij zdarzenie "internal mouse move" do sfokusowanej kontrolki.
| | - Wyślij zdarzenie "internal mouse cursor" do sfokusowanej kontrolki.
| | - Ustaw nowy kształt kursora.
| - ...w przeciwnym razie (mosue capture trwa):
| - Jeśli kursor poruszył się:
| - Wyślij zdarzenie "internal mouse move" do przechwytywanej kontrolki.
- ...w przeciwnym razie (kontrolka została ukryta lub zablokowana):
- Wyślij zdarzenie "common focus out" do sfokusowanej kontrolki.
- Wyślij zdarzenie "internal mouse cursor" do sfokusowanej kontrolki.
- Ustaw nowy kształt kursora.
- Wyłącz mouse capture w UI box.
- Zresetuj dane fokusu w UI box.
- Zresetuj dane ostatnio klikniętej kontrolki.
- Wyjdź przerywając "mouse update" i cały "update".
Przypadek 3. - mouse capture jest nieaktywny:
- Określ kontrolkę znajdującą się pod kursorem.
- Jeśli kursor znajduje się nad którąś kontrolką:
| - Jeśli kontrolka jest sfokusowana w UI box:
| - Jeśli sfokusowana jest inna kontrolka niż ta pod kursorem:
| - Wyślij zdarzenie "common focus out" do sfokusowanej kontrolki.
| - Zresetuj dane fokusu w UI box.
| - Zresetuj dane ostatnio klikniętej kontrolki.
| - Jeśli pod kursorem jest inna kontrolka niż ta sfokusowana:
| - Jeśli ta kontrolka jest odblokowana:
| - Ustaw kontrolkę jako sfokusowaną w UI box.
| - Wyślij zdarzenie "common focus in" do sfokusowanej kontrolki.
| - Wyślij zdarzenie "internal mouse move" do sfokusowanej kontrolki.
| - Wyślij zdarzenie "internal mouse cursor" do kontrolki spod kursora.
| - Ustaw nowy kształt kursora.
| - Jeśli kontrolka jest sfokusowana:
| - Jeśli kontrolka jest widoczna i odblokowana:
| | - Jeśli kursor poruszył się i fokus nie przeszedł na inną kontrolkę:
| | - Wyślij zdarzenie "internal mouse move" do sfokusowanej kontrolki.
| | - Jeśli kursor opuścił obszar przechwytywania scrollowania przez UI box:
| | - Zresetuj flagę przechwytywania scrollowania przez UI box.
| | - Wyślij zdarzenie "internal mouse cursor" do sfokusowanej kontrolki.
| | - Ustaw nowy kształt kursora.
| | - Jeśli lewy przycisk myszy został świeżo wciśnięty:
| | - Ustaw sfokusowaną kontrolkę jako ostatnio klikniętą.
| | - Wyślij zdarzenie "internal mouse down" do sfokusowanej kontrolki.
| | - Wyślij zdarzenie "internal mouse cursor" do sfokusowanej kontrolki.
| | - Ustaw nowy kształt kursora.
| | - Wyślij zdarzenie "internal mouse test capture" do sfokusowanej kontrolki.
| | - Jeśli kontrolka zaapkceptowała mouse capture:
| | - Włącz mouse capture w UI box.
| | - Wyjdź przerywając "mouse update" i cały "update".
| | - Jeśli lewy przycisk myszy został świeżo puszczony:
| | - Jeśli to ta sama kontrolka co ostatnio kliknięta:
| | - Wyślij zdarzenie "internal mouse up" do sfokusowanej kontrolki.
| | - Zresetuj dane ostatnio klikniętej kontrolki.
| | - Wyślij zdarzenie "internal mouse cursor" do sfokusowanej kontrolki.
| | - Ustaw nowy kształt kursora.
| | - Wyjdź przerywając "mouse update" i cały "update".
| | - Jeśli rolka myszy została obrócona:
| | - Jeśli flaga przechwytywania scrollowania przez UI box nie jest ustawiona:
| | - Wyślij zdarzenie "internal select" do sfokusowanej kontrolki.
| | - Jeśli kontrolka obsłużyła zdarzenie zmiany zaznaczenia:
| | - Wyjdź przerywając "mouse update" i cały "update".
| | - Dodaj zdarzenie "common scroll" do kolejki UI box.
| | - Jeśli flaga przechwytywania scrollowania przez UI box nie jest ustawiona:
| | - Zapamiętaj pozycję kursora dla ostatniego obrotu rolki myszy.
| | - Ustaw flagę przechwytywania scrollowania przez UI box.
| | - Wyjdź przerywając "mouse update" i cały "update".
| - ...w przeciwnym razie (kontrolka została ukryta lub zablokowana):
| - Wyślij zdarzenie "common focus out" do sfokusowanej kontrolki.
| - Wyślij zdarzenie "internal mouse cursor" do sfokusowanej kontrolki.
| - Ustaw nowy kształt kursora.
| - Zresetuj dane fokusu w UI box.
| - Zresetuj dane ostatnio klikniętej kontrolki.
- ...w przeciwnym razie (kursor znajduje się nad pustym obszarem UI box):
- Jeśli kontrolka jest sfokusowana:
| - Wyślij zdarzenie "common focus out" do sfokusowanej kontrolki.
| - Zresetuj dane fokusu w UI box.
| - Zresetuj dane ostatnio klikniętej kontrolki.
- ...w przeciwnym razie (nie ma sfokusowanej kontrolki w UI box):
- Jeśli kursor znajduje się w obszarze UI box:
- Jeśli rolka myszy została obrócona:
- Dodaj zdarzenie "common scroll" do kolejki UI box.
- Jeśli flaga przechwytywania scrollowania przez UI box nie jest ustawiona:
- Zapamiętaj pozycję kursora dla ostatniego obrotu rolki myszy.
- Ustaw flagę przechwytywania scrollowania przez UI box.
- Ustaw domyślny kształt kursora.
- Wyjdź przerywając "mouse update", ale nie cały "update".
Trzy główne przypadki, wymienione w tej liście, to trzy osobne funkcje, wywoływane kontekstowo w głównej funkcji aktualizacji UI na podstawie inputu myszy. Cały proces aktualizacji UI (mysz, klawiatura i joysticki) to jakieś 1000-1200 linijek kodu, natomiast cała implementacja UI zajmuje obecnie około 3200 linijek kodu (z komentarzami dokumentacji około 5200).
Trochę ficzerów upchnąłem w tym kernelu i jestem z niego bardzo zadowolony.
Czy ty nie miałeś wcześniej nicku furious programmer? Co się stało, że nick zmieniłeś? :(