furious programming
2019-12-05 03:37

Kolejny wpis na temat bazgrołów graficznych w Lazarusie, bo dawno nic na ten temat nie pisałem. :)

Powoli kończę prace nad narzędziem do organizowania i wygodnego przeprowadzania stacjonarnych mistrzostw w Tetrisa. Obecnie finalizuję implementację animowanego banera, który u dołu okna podglądu będzie wyświetlał różne teksty i adresy kont w serwisach społecznościowych (małą próbkę pokazałem w wątku Łączenie dwóch obrazów w jeden na podstawie zadanego poziomu – można sobie zobaczyć o co chodzi). Aby użytkownik mógł sobie konfigurować zawartość i zachowanie takiego banera, potrzebny jest interfejs do modyfikowania jego ustawień, z jednoczesnym podglądem końcowego efektu. I małą część tego interfejsu przedstawię w tym wpisie.


Renderowanie dwukolorowego tekstu

Niektóre etykiety banera służą do wyświetlania tekstu, którego fragmenty mogą być malowane używając jednego z dwóch zadanych kolorów – w celu wizualnego podziału linijki tekstu na fragmenty ”godne uwagi”, coby nie cudować z interpunkcją. Czyli w skrócie: mamy linijkę tekstu, w tym tekście wstawione są znaki określające ”swap” bieżącego koloru (tym znakiem jest |), mamy współrzędne określające pozycję docelową tekstu i mamy dwa kolory (startowy i dodatkowy). Wynikiem działania ma być dwukolorowy tekst wyrenderowany na zadanym płótnie.

procedure DrawTextStriped(ACanvas: TCanvas; AX, AY: Integer; AColorA, AColorB: TColor; const AText: String);
var
  Stripes: TStringList;
  Stripe: String;
var
  StripeColors: array [Boolean] of TColor;
  StripeColorIndex: Boolean = False;
begin
  Stripes := TStringList.Create();
  try
    if ExtractStrings(['|'], [], PChar(AText), Stripes, True) > 0 then
    begin
      StripeColors[False] := AColorA;
      StripeColors[True] := AColorB;

      for Stripe in Stripes do
      begin
        ACanvas.Font.Color := StripeColors[StripeColorIndex];
        ACanvas.TextOut(AX, AY, Stripe);

        AX += ACanvas.TextWidth(Stripe);
        StripeColorIndex := not StripeColorIndex;
      end;
    end;
  finally
    Stripes.Free();
  end;
end;

Procedurka bardzo prosta w działaniu. Do pomocniczej listy łańcuchów wypakowujemy fragmenty tekstu, rozdzielone według znaku markera (koniecznie z zachowaniem białych znaków, dlatego drugi parametr otrzymuje pusty zbiór). Kolory pakujemy do macierzy indeksowanej wartością logiczną (bo najłatwiej odwrócić indeks bieżącego koloru – wystarczy prosta negacja) i ustawiamy indeks bieżącego koloru na pierwszą komórkę (o indeksie False). Następnie w pętli renderujemy bieżący fragment, inkrementujemy pozycję horyzontalną o pikselową szerokość fragmentu i odwracamy (negujemy) indeks koloru. To wszystko.

Przykładowe wykorzystanie powyższej procedury do namalowania tekstu na płótnie formularza, w zdarzeniu OnPaint okna:

procedure TMainForm.FormPaint(ASender: TObject);
begin
  Canvas.Brush.Color := clWindow;
  Canvas.FillRect(ClientRect);

  Canvas.Font.Name := 'Gotham Black';
  Canvas.Font.Size := 19;

  DrawTextStriped(Canvas, 16, 16, clWindowText, clHighlight, 'LOREM IPSUM |DOLOR SIT| AMET');
end;

Efekt działania:

0.png

Fajnie, możemy już malować ”pasiasty” tekst, no ale użytkownik musi go jakoś wprowadzać i widzieć podgląd w kontrolkach, a nie na gołym płótnie okna – dlatego czas na przygotowanie interfejsu.


Renderowanie podglądu w komponencie

Do podglądu etykiety skorzystamy z kontrolki typu TPaintBox, bo służy do malowania różnych pierdół i przy okazji nie obsługuje focusa. Jakie tło wybrać? Przecież jednolite będzie wyglądać brzydko, w przypadku gdy kolor tła będzie inny niż komponentu nadrzędnego (lub okna). Do wprowadzania tekstu wykorzystamy zwykły TEdit, więc idąc za ciosem, niech nasz paintbox też wygląda jak pole edycyjne.

No dobrze, ale jak namalować obramowanie pola edycyjnego w paintbox? W końcu nie wiadomo jakich kolorów używa system… Tu z pomocą przychodzi obiekt ThemeSevices z modułu Themes. ;)

Jego użycie jest banalne – wystarczy pobrać ”detale” za pomocą metody GetElementDetails, podając enum określający interesujący nas element. Tak pozyskane detale następnie należy podać metodzie DrawElement, razem z uchwytem płótna oraz obszarem docelowym:

procedure DrawBannerBackground(ACanvas: TCanvas; ARect: TRect);
var
  Details: TThemedElementDetails;
begin
  Details := ThemeServices.GetElementDetails(teEditTextNormal);
  ThemeServices.DrawElement(ACanvas.Handle, Details, ARect);
end;

Tej procedurki używamy wewnątrz zdarzenia OnPaint kontrolki paintbox:

procedure TMainForm.CBannerPaintBoxPaint(ASender: TObject);
var
  PaintBox: TPaintBox absolute ASender;
begin
  DrawBannerBackground(PaintBox.Canvas, PaintBox.ClientRect);
end;

W rezultacie na ekranie zobaczymy jedno pole, które faktycznie jest edycyjnym (górne) oraz drugie, które służy tylko do podglądu (dolne):

1.png

Tło mamy z głowy – teraz ostatnia rzecz, czyli namalowanie tekstu wewnątrz niby-edita.


Renderowanie tekstu etykiety

Procedurkę do renderowania pasiastego tekstu już mamy, więc pozostało wyznaczyć odpowiednią pozycję tekstu i go namalować. Ale tym razem wyrenderujemy go wyśrodkowanego w poziomie i z odpowiednio wypozycjonowanego w pionie, aby zachować równe odstępy od krawędzi. No to machnijmy drugą procedurę:

procedure DrawBannerLabel(ACanvas: TCanvas; ARect: TRect; AColorA, AColorB: TColor; const AText: String);
var
  Text: String;
  TextWidth, TextLeft, TextTop: Integer;
begin
  ACanvas.Font.Name := 'Gotham Black';
  ACanvas.Font.Size := 19;
  ACanvas.Brush.Style := bsClear;

  Text := AText.Trim();
  TextWidth := ACanvas.TextWidth(StringReplace(Text, '|', '', [rfReplaceAll]));

  TextLeft := ARect.Left + (ARect.Width - TextWidth) div 2;
  TextTop := ARect.Top + 3;

  DrawTextStriped(ACanvas, TextLeft, TextTop, AColorA, AColorB, Text);
end;

Tutaj trochę magii. Nie możemy centrować surowego tekstu z parametru, dlatego że ten zawiera znaki markera, które nie będą renderowane na ekranie. Dlatego przed zmierzeniem szerokości tekstu, najpierw należy z niego usunąć te znaki – stąd użycie StringReplace. Wywołanie przygotowanej procedury należy dodać do zdarzenia OnPaint i tak samo jak wcześniej określić parametry, ale zamiast gołego tekstu, należy przekazać zawartość pola edycyjnego:

procedure TMainForm.CBannerPaintBoxPaint(ASender: TObject);
var
  PaintBox: TPaintBox absolute ASender;
begin
  DrawBannerBackground(PaintBox.Canvas, PaintBox.ClientRect);
  DrawBannerLabel(PaintBox.Canvas, PaintBox.ClientRect, clWindowText, clHighlight, CBannerEdit.Text);
end;

To wszystko – teraz wystarczy wywołać Invalidate paintboxa w zdarzeniu OnChange pola edycyjnego i można cieszyć oko kontrolką pokazującą na żywo końcowy efekt. Normalnie technologia WYSIWYG. ;)

2.png

Dzięki wykorzystaniu tła systemowej kontrolki oraz kolorów zgodnych ze skórką, zawartość paintboxa zawsze będzie wyglądać dobrze, nieważne na jakim systemie i konfiguracji. Ba, nawet jeśli użytkownik zmieni schemat podczas działania programu, to jego okna zostaną odmalowane po takiej zmianie, a więc i komponenty dostosują swój wygląd do nowych ustawień.

3.png


Podsumowanie

Jak widać roboty niewiele, a efekt końcowy całkiem przyzwoity. Oczywiście to co opisałem wyżej do tylko wierzchołek góry lodowej – w swoim projekcie wykorzystuję znacznie bardziej zaawansowane renderowanie banera (tekst z ikonkami, opcjonalnie gradientowe tło), a także podobną technikę stosuję do renderowania podglądu profilu gracza, zawierającego najróżniejszy tekst, separator, flagę i ”naklejki” ze specjalnymi osiągnięciami. Natomiast systemowe kolory wykorzystywane są nie tylko do tekstu, ale też do barwienia półprzezroczystych grafik naklejek, a podczas wprowadzania danych, aktywny element jest renderowany w kolorze zaznaczenia. Ale to już inna bajka – więcej na jej temat już niebawem.

To co podałem wyżej powinno wystarczyć do prostych zastosowań, nakreślić sposób wykorzystania ”renderera” systemowych komponentów oraz skłonić do tworzenia programów desktopowych w taki sposób, aby zachować kompatybilność z ustawioną przez użytkownika skórką. ;)

#fpc #free-pascal #lazarus

cerrato

Fajnie się czyta, jak opisujesz sposób działania poszczególnych kontrolek/mechanizmów. Ale dopiero wrażenie robi, gdy to wszystko zepniesz w całość :)

furious programming

Staram się tak pisać tego typu artykuły tak aby ciekawie się je czytało, ale też przedstawiać po kolei nasuwające się pytania i problemy, tak jakby czytający sam nad tym pracował i sam sobie je zadawał. To pozwala się wczuć w rolę twórcy, zamiast jedynie mieć wrażenie obserwatora. ;)

cerrato

Tylko tak myślę, czemu to zrobiłeś jako ręczne malowanie na Canvas, a nie jako po prostu komponent dziedziczący po TLabel. No i jeszcze jedna rzecz mnie dziwi - godzina 8, a Ty już na nogach. No chyba, że "jeszcze" ;)

furious programming

Wszystko dlatego, aby móc namalować zarówno sam pasiasty tekst, jak i cały baner na płótnie dowolnego obiektu – formularza, komponentu czy na wirtualnej bitmapie. Dzięki temu użyteczność tego kodu nie jest ograniczona.


W przypadku mojego głównego projektu ma to ogromne znaczenie, dlatego że dokładnie ten sam kod malowania etykiet banera wykorzystywany jest zarówno do renderowania podglądu w oknie dialogowym (do konfigurowania banera), w którym do podglądu używam paintboxów (i systemowych kolorów), a renderowanie właściwego banera w docelowym oknie realizowane jest bezpośrednio na płótnie okna (z użyciem predefiniowanej palety kolorów).

Natomiast baner, jako kompletny mechanizm, reprezentowany jest przez zestaw dedykowanych klas, które opakowują w sobie przygotowanie wszystkich wybranych przez użytkownika etykiet i ich wyrenderowanie na wirtualnych bitmapach, a także zapewniają ich organizację (odpowiednie ułożenie w liście generycznej) oraz aktualizowanie bufora bieżącej klatki animacji. Cała logika zarządzania etykietami i animowania znajduje się w głównej klasie banera.

Ostatecznie bufor bieżącej klatki jest cały czas dostępny za pomocą przygotowanej właściwości, dzięki czemu w metodzie renderującej cały obraz (tło sceny, dane dynamiczne i baner) wystarczy go pobrać i namalować na płótnie okna. ;)

cerrato

No OK, ale z drugiej strony trochę się to kłóci z ideą RAD, czyli składania apki z klocków. Ale rozumiem ideę zewnętrznej biblioteki/klasy, która odpowiada za kolorowanie i renderowanie napisów. Tylko aż się prosi, żeby kolejnym krokiem było stworzenie kilku komponentów (np. wspomniany wcześniej potomek TLabel), które ten mechanizm będą wykorzystywać.

furious programming

W przypadku mojego projektu nie kłóci się z ideą RAD, dlatego że finalny obraz, który jest rejestrowany przez oprogramowanie nagrywające (np. OBS) i tak jest dynamicznie renderowany bezpośrednio na płótnie okna. A program wykorzystuje dwa okna – jedno jako panel sterowania (z zakładkami i cała masą komponentów z różnymi opcjami) oraz drugie, wyłącznie do dynamicznego renderowania obrazu dla nagrywarki (bez obramowania i bez żadnych komponentów).

Nie ma absolutnie żadnych podstaw, ani nawet fizycznej możliwości, aby w tym drugim oknie zastosować komponenty. Wszystko dlatego, że sceny mają różne tła i różne dynamicznie malowane informacje, stąd musiałbym mnóstwo tych kontrolek użyć, a to nie dość że bardzo mocno ograniczy i utrudni mi wprowadzanie zmian i rozwijanie projektu, to jeszcze negatywnie odbije się na wydajności.

cerrato

A tak technicznie - jak będzie pozyskiwany obraz do nagrania? Jakaś karta-tuner TV? W jaki sposób zgarniesz analogowy sygnał idący na telewizor i go przeniesiesz do kompa?

furious programming

Tym już zajmować się będzie oprogramowanie do nagrywania, które sobie użytkownik wybierze – np. OBS, ale to nie jest jedyny program do tego celu. W każdym razie fizycznie sygnał z konsol będzie przechwytywany przez zwykłe grabbery USB (są tanie i popularne), z kamer(y) to już leci sygnał cyfrowy.

Proces montażu klatki obrazu wygląda w ten sposób, że np. OBS pobierze dwie klatki z grabberów, klatkę z kamery nagrywającej graczy (lub dwóch), skopiuje zawartość okna mojego programu, ew. dodatkowo skorzysta z klatki/efektu przejścia i zmontuje te wszystkie obrazki w całość, w odpowiedniej kolejności.

cerrato

Aaaa...czyli Ty de facto samym przetwarzaniem obrazu z kamery/grabbera się nie zajmujesz - dobrze rozumiem?

furious programming

Dokładnie. ;)

Mój program w obecnej wersji umożliwia gromadzenie i zarządzanie danymi na temat scen, graczy, wyników kwalifikacyjnych, meczów turniejowych itd., a także implementuje dość skomplikowaną logikę generowania drabinek i renderowania danych w oknie podglądu (tła, informacji dostępnych dla danej sceny i bannera). Ogólnie pozwala na obsługę całych mistrzostw, od kwalifikacji aż po mecz finałowy, za pomocą raptem kilku kliknięć. Do przełączania scen są odpowiednie przyciski, zawsze pod ręką, a samo przełączenie sceny nie tylko aktualizuje zawartość okna podglądu, ale też powiadamia zewnętrzną nagrywarkę aby i ta zmieniła scenę. Cud, miód i orzeszki.

Jeśli ludziom spodoba się ten projekt i zechcą go wesprzeć, to zakupię trochę sprzętu i będę dodawał do programu kolejne funkcje – odmalowywanie stosów w czasie rzeczywistym (aby mieć obraz ostry jak żyleta, czego żaden grabber nie zapewnia), dodatkowe liczniki, statystyki itd. Po pewnym czasie będzie można dodać przechwytywanie obrazu z kamer i grabberów, a także efekty przejść pomiędzy scenami itd., czyli wprowadzać nowości dotąd, aż zaimplementuję pełną funkcjonalność i zewnętrzny program nagrywający przestanie być potrzebny.

Ale powtarzam – wszystko zależy od tego, czy ludzie zechcą ten projekt wesprzeć. Jeśli nie, to trudno – zakończę jego rozwój, a samo narzędzie pozostanie na wyłączny użytek CTWC Poland, aby nasze krajowe mistrzostwa wyglądały przyzwoicie, a nie były zlepkiem prymitywnych obrazów z Excela, drabinek generowanych w przeglądarce i danych uzupełnianych naprędce pomiędzy meczami.

cerrato

Wyjaśnij, czemu logika drabinek jest skomplikowana? Ja się sportem - ani prawdziwym, ani elektronicznym, nie interesuję, ale zawsze mi sie wydawało, ze drabinku sa czymś prostym. Na najniższy poziomie masz pary, potem z każdej pary wychodzi jeden gracz, gra na kolejnym poziomie z kimś, kto wygrał swój pojedynej i tak co poziom, aż w końcu mamy zwycięzców.

Co oznacza "odmalowywanie stosów"? Chcesz jakoś ingerować w obraz pozyskany z grabbera? Z tego co zrozumiałem, to na razie Ty tego obrazu nie analizujesz, nie zmieniasz, a jedynie składasz/nakładasz napisy na niego/osadzasz w swoim okienku

furious programming

Wyjaśnij, czemu logika drabinek jest skomplikowana?

Spróbuj sobie wyobrazić kod odpowiedzialny za generowanie i pełną obsługę drabinek, zakładając, że:

  1. Na wejściu dostajesz generyczną listę obiektów graczy, posortowanych malejąco według wyników kwalifikacyjnych.
  2. Niektórzy gracze mogą nie posiadać wyników kwalifikacynych, niektórzy mogą posiadać identyczne wyniki, niektórzy nawet max outy lub ich nawet kilka na łebka.
  3. Lista wejściowa może zawierać od dwóch do n graczy, gdzie n może być dowolnie duże.
  4. Obsługiwane typy drabinek to te dla:
    1. 8 graczy,
    2. 12 graczy, z dodatkową rundą zero,
    3. 16 graczy,
    4. 20 lub 24 graczy – obie z rundą zero,
    5. 32 graczy,
    6. 40 lub 48 graczy – obie z rundą zero.
  5. W przypadku większej liczby graczy niż 48, tylko 48 może awansować (reszta odpada).
  6. Poprawna drabinka z osadzonymi graczami z listy wejściowej musi być zawsze możliwa do wygenerowania, nawet jeśli nie wszystkie miejsca w danej drabince zostaną obsadzone (np. lista wejściowa zawiera 5 graczy, więc należy wybrać drabinkę dla 8 graczy, w której trzy miejsca pozostają wolne).
  7. W przypadku nieobsadzonych miejsc w drabinkach, gracze bez pary muszą być przepchnięci do kolejnych meczów (w przypadku jedynie dwóch graczy, aż do samego meczu finałowego, bo program musi pozwolić zorganizować pojedynczy mecz).
  8. Każdy z meczów może zostać zresetowany, jeśli jego zwycięzca jeszcze nie rozegrał kolejnego meczu.
  9. Musi być obsługa meczu o trzecie miejsce, a więc przepychać do kolejnych meczów należy nie tylko zwycięzców, ale też i przegranych (dwóch przegranych w półfinałach, bo przegrani z wcześciejszych rund mają odpaść).
  10. W dowolnym momencie trwania turnieju, musi być możliwość zablokowania i odblokowania meczu o trzecie miejsce.
  11. Wiele wiele innych niuansów, mocno komplikujących implementację.

A jakby tego było mało, program musi posiadać przycisk o treści Show brackets, który nie dość że ma włączyć odpowiednią scenę aby pokazać na ekranie drabinki dla bieżącej rundy, to w dodatku musi też dynamicznie wyrenderować na tych drabinkach nazwy graczy – w odpowiednich miejscach i o odpowiednim wyglądzie (z ”ziarnem” kwalifikacyjnym lub bez i w odpowiednim kolorze/kolorach fontu).

Nie wspomnę już o tym, że wybór awansujących do drabinek graczy oraz ustawienia meczów każdej rundy muszą być zrealizowane za pomocą okna dialogowego z odpowiednimi kontrolkami, pozwalającymi wygodnie i szybko wszystko ustawić, a także o renderowaniu listy meczów w sposób czytelny w kontrolce typu CheckListBox. ;)

Pepe

Ja tylko dodam, że czytam, obserwuje i podziwiam :P Poza tym, zawsze się dziwię wam, że tak dużo czasu spędzacie na forum... pisanie tych postów, komentarzy zajmuje przecież mnóstwo czasu... dziękuję za to! Niektórzy z Was naprawdę się przykładają do swojej roli (a przecież w tym czasie moglibyście tworzyć coś komercyjnego, lub cokolwiek)...

cerrato

Spójrz na to inaczej - ci, którzy do czegoś doszli w realnym życiu, mają lepsze zajęcia, niż siedzenie 24/7 na forum. Czyli ludzie z największą aktywnością to są po prostu przegrywy, których jedynym życiowym osiągnięciem jest duża ilość postów na 4P :P

furious programming

@Pepe: to że jestem zalogowany przez 12h dziennie, wcale nie oznacza, że przez tyle czasu przeglądam forum. A tak naprawdę, gdyby policzyć, na faktycznym czytaniu i pisaniu na forum nie spędzam więcej niż godzinę dziennie. ;)

Niektórzy z Was naprawdę się przykładają do swojej roli (a przecież w tym czasie moglibyście tworzyć coś komercyjnego, lub cokolwiek)...

Jeśli o mnie chodzi to większość technicznych wpisów na blogu zawiera informacje i źródła wyciągnięte z opracowywanych projektów. Np. ostatnie kilka wpisów dotyczą bieżącego projektu, czyli narzędzia do Tetrisa (renderowanie, łatanie bugów kontrolki CheckListBox, półprzezroczysty Splash Screen itp.).

Z chęcią bym napisał coś więcej, opisał implementację konkretnego edytorka (używanego w głównym projekcie, bo te są znacznie bardziej zaawansowane), ale trwało by to dość długo, a po drugie, musiałbym pisać na raty i porozbijać całość na kilka wpisów (bo te mają ograniczenie długości), co zjadłoby jeszcze więcej czasu. Doba musiała by mieć 30 godzin.