TL;DR: demówka do testowania nowego UI jest tutaj — WtNC — UI kernel.zip
Prace nad silnikiem zostały ukończone
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:
- Edytor charsetów —
3,593
linijki kodu, - Edytor fontów —
9,143
linijki kodu, - Edytor kursorów —
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.
Wsparcie kontrolek do edytowania tekstu
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.
Obiekt edytora tekstu
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:
Kontrolki UI do wprowadzania tekstu
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.
Kernel UI i wsparcie edycji tekstu
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:
- akceptując wprowadzone w tekście zmiany, za pomoca klawisza Enter lub triggerem akceptacji,
- odrzucając wprowadzone zmiany, za pomocą klawisza Esc lub triggerem odrzucenia,
- wciskając trigger nawigacji pionowej, czyli np. klawisz ↑ lub ↓,
- klikając poza obszarem kontrolki z aktywnym edytorem tekstu:
- w pustym obszarze UI box,
- w inną kontrolkę tego samego lub innego UI box,
- poza obszarem któregokolwiek UI box (np. w puste miejsce w oknie).
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.
Dziwna ta obsługa wpisywania tekstu?
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).
Demo do testowania kernela i kontrolek UI
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:
- Żółte — imitują kontrolki statyczne, nie obsługują hovera i fokusu, przezroczyste dla inputu myszy, nie wspierające przechwytywania.
- Zielone — imitują zwykłe przyciski, obsługują hover i fokus, nie wspierają przechwytywania.
- Czerwone — imitują różnego rodzaju suwaki, obsługują hover i fokus, wspierają przechwytywanie myszy,
- Niebieskie — imitują pola edycyjne, obsługują hover i fokus, wspierają przechwytywanie edycji i myszy.
Dodatkowo, te dwie w prawym górnym rogu są przypięte do ekranu. Klawiszologia:
- 1, 2, 3, 4 — odblokuj, zablokuj, pokaż, ukryj żółte kontrolki,
- Q, W, E, R — odblokuj, zablokuj, pokaż, ukryj zielone kontrolki,
- A, S, D, F — odblokuj, zablokuj, pokaż, ukryj czerwone kontrolki,
- Z, X, C, V — odblokuj, zablokuj, pokaż, ukryj niebieskie kontrolki.
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:
- sam obrót kółka myszki — podstawowe przewijanie,
- z klawiszem Ctrl — przewijanie co 1px,
- z klawiszem Shift — szybkie przewijanie.
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.
Cel i ograniczenia demówki
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.
@Manna5: po linijkach. Poprawię wpis, żeby to było jednoznaczne.
Przy czym to wartość raczej średnia, dlatego że UCLC osobno liczy linie kodu, osobno puste linie, a osobno komentarze. Ale nie mam pojęcia, czy jak dana linijka zawiera kod i komentarz, to jest liczona tu i tu. Ot to takie proste statystyki, dla ogólnego przedstawienia rozmiaru projektów.
@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.
W jaki sposób liczone? Znaki komentarzy na znaki niebędące komentarzami, po linijkach czy jeszcze inaczej?