Wstęp teoretyczny:Symulator ma w siebie wbudowany moduł pythona interpretujący kod programu rysujący ekran. Jest on niekompilowany, możemy go dowolnie modyfikować notatnikiem przed inicjalizacją symulacji.
Moduł podmienia teksturę jednego obiektu w kabinie pojazdu generowaną dynamicznie.
pyscreen: komp ekranik
Oznacza, że ekranem będzie obiekt (submodel w kabinie) "komp" a jego tekstura będzie generowana skryptem/funkcją "ekranik". Jeśli w danej kabinie nie zostanie znaleziony taki obiekt, zostanie on zignorowany.
Jak widać mamy tylko jeden obiekt, który w t3d może mieć tylko jedną teksturę. Przy kilku ekranach w pojeździe musimy je wszystkie scalić w jeden obiekt i mapować z różnych części jednej dużej tekstury. Zniesione. Pojazd może obsłużyć dowolną ilość skryptów.
Liczenie tekstury odbywa się w osobnym wątku, którego priorytet względem symulacji można ustawić w eu07.ini wpisem:
pyscreenrendererpriority normal // (normal, lower, lowest, idle) priorytet wątku pythonowego renderera. Odciąża procesor zmniejszając odświeżanie ekranów w lokomotywach.
Ciężki skrypt na wolnym komputerze może znacznie spowolnić symulację. Niższy priorytet za to powodować, że klatka ekranu będzie liczona nawet raz na kilka tysięcy klatek symulacji, przez co ciężko będzie nawet dostrzec animację tekstury.
W pętli symulatora część wartości fizycznych naszego pojazdu/składu jest udostępniana do słownika pythona. Lista kluczy dostępna jest tutaj:
https://wiki.eu07.pl/index.php/PythonKlucze rozpoczynające się frazą "eimp" oznaczają "electric induction motor parameter" i nie działają dla innych typów pojazdów. Niekiedy dokumentacja może też trochę wyprzedzać publiczne wersje exe.
Wartości fizyczne ze słownika mogą być wykorzystane w naszym programie. Dodatkowo możemy używać wartości uzyskanych z bibliotek wbudowanych w środowisko pythona. W ten sposób uzyskiwana jest choćby data systemowa, jako że symulacja nie ustawia konkretnej fikcyjnej daty.
Cały program jest jak zapewne się domyślacie napisany w języku python, więc wymagana jest podstawowa znajomość składni. Zazwyczaj nie trzeba wymyślać koła od nowa. Ekrany zawierają podobne dane. Wystarczy zmienić ich reprezentację graficzną i ustawić w dobrym miejscu, modyfikując gotowy kod.
Warto jednak przejrzeć podstawy
https://docs.python.org/3/ lub znaleźć sobie dowolny, polskojęzyczny poradnik by poznać zasady inicjalizacji zmiennych, budowy funkcji, pętli, warunków.
Używamy głównie zmodyfikowanej biblioteki PIL-
PILLOW.
https://pillow.readthedocs.io/en/3.4.x/ W szczególności modułu
ImageDraw.
Pozwala on na wczytanie grafiki tła, nakładanie na nią innych grafik, generowanie prostej grafiki wektorowej, napisów i eksport w postaci nowej bitmapy.
Taki proces właśnie jest przeprowadzany w naszym programie. Bierzemy teksturę ekranu, na podstawie zaczerpniętych i odpowiednio przeliczonych wartości fizycznych pojazdu rysujemy na niej napisy, słupki, diagramy, nakładamy ikonki i eksportujemy do bufora. Ten obrazek jest wysyłany do menadżera tekstur w głównym wątku symulatora i nakładany na obiekt zdefiniowany w mmd pojazdu.
Przykład:Dostałem od @EP08_015 prośbę:
Data aktualna, predkosc cyfrowa pod predkoscia pasek rozszerzajacy sie domyslam sie ze to procent max szybkosci oraz oststni prad trakcyjny cyfrowy z paskiem sily trakcyjnej.
Ekranik musi się świecić w nocy. Świecenie jest atrybutem materiału obiektu a nie tekstury, więc pythonem na nie nie wpłyniemy. Ekranik ma się więc świecić cały czas/być podłączony pod universala. Częścią świecącą będzie sam czarny prostokącik (świecenie na czarno=brak świecenia).
Na teksturze którą otrzymałem ma on rozdzielczość 158x124 px. Nie wiem jaką ma w rzeczywistości, ale że ma być teksturą wyświetlaną przez symulator, musi mieć wymiary 2^n i jak wszystkie tekstury dynamiczne, mieć głębię 24 bitów.
Zeskalowałem podkładkę do 256^2 px. U dołu został biały pasek. Niestety musi zostać. Część tekstury się zmarnuje. Tak to wygląda obecnie:
Potrzebujemy więc:
1. Szare tło 256x256 px.
2. Pikselową czcionkę do cyferek.
3. Godzinę w symulacji.
4. Prędkość pojazdu.
5. Prąd trakcyjny silnika spal-ele.
6. Siłę trakcyjną.
Punktów 5-6 nie zrobimy, bo nie są zaimplementowane w exe471.
Otwieramy notatnik i zaczynamy pisać program.
Potrzebujemy tła. Zazwyczaj bierzemy jakiś rysunek na tło, ale tu jest jednorodne, wiec nie ma to sensu. Generujemy nowy plik.
https://pillow.readthedocs.io/en/3.4.x/reference/Image.html#constructing-imagesZ biblioteki
PIL wczytujemy moduł
Image. W nagłówku piszemy:
from PIL import Image
Mamy teraz dostęp do wszystkich funkcji klasy Image
Tworzymy podstawową strukturę programu.
class ekranik(abstractscreenrenderer):
def __init__(self,lookup_path,name,cab):
#tu będą inicjowane stałe
def _render(self, state):
#tu będą zmienne odświeżane w każdej klatce
W metodzie
render tworzymy nasze tło. Musi się odświeżać w każdej klatce by zakrywać stare napisy.
obrazek=Image.new(mode, size, color=0)
mode=RGB (3x8-bit pixels, true color)
size=(256,256)
color=(57,57,57) (ciemnoszary, takie rgb odczytało mi z podkładki)
obrazek=Image.new(RGB, (256,256), color=(57,57,57))
I na końcu metody zwracamy finalny obrazek do bufora:
return obrazek
By móc coś rysować po naszym obrazku używamy funkcji
Draw z modułu
ImageDraw z biblioteki
PIL.
Dodajemy ją do nagłówka. Razem z poprzednią będziemy mieć:
from PIL import Image, ImageDraw
W metodzie
render po wygenerowaniu tła, ładujemy je do edycji, przypisując funkcję pod zmienną
draw by nie pisać tego przy każdej funkcji korzystającej z niej później:
draw=ImageDraw.Draw(obrazek)
Zegarek:Kopiujemy gotowy kod z traxxa:
#czas
if state['seconds'] != self.last_time_update:
dt = state['seconds'] - self.last_time_update
if dt < 0:
dt+=60
self.kilometry += dt*speed * 0.0002778
self.last_time_update = state['seconds']
if state['hours']<10: #dodaje zero przed liczbą gdy ma tylko jedną cyfrę
godz = "0" + str(state['hours'])
else:
godz = str(state['hours']) #zapisuje do zmiennej "godz" liczbę godzin jako string
if state['minutes']<10:
min = "0" + str(state['minutes'])
else:
min = str(state['minutes'])
if state['seconds']<10:
sec = "0" +str(state['seconds'])
else:
sec = str(state['seconds'])
Licznika kilometrów nam nie potrzeba, więc go usuwamy.
Mamy trzy zmienne z godzinami, minutami i sekundami. Wyświetlamy je na ekranie:
draw.text((x,y), godz +":"+ min +":"+ sec, fill, font)
Musimy podać pozycję, kolor i czcionkę napisu. Użyjemy obecnej już w symulatorze czcionki Alterebro Pixel Font Regular. Otwieram w gimpie podkładkę i piszę nią napis by pokrył się ze zdjęciem. Odczytuję:
Lewy górny róg ramki tekstu (zero w grafice rastrowej): (7, 20)
Rozmiar: 68
Kolor jest strasznie zszarzały. Wezmę intensywniejszy (193,164,50)
W metodzie init definiuję czcionkę:
self.pixel = ImageFont.truetype(lookup_path + "Alterebro Pixel Font Regular.ttf", 68)
Używam modułu ImageFont, więc dodaję go do nagłówka. Mamy tam już:
from PIL import ImageDraw, ImageFont, Image
Dodajemy również nasz kolor. Można go za każdym razem podawać jako RGB, ale w ten sposób w jednym miejscu możemy go zmieniać jakby efekt był niezadowalający.
self.zolty = (193,164,50)
Pod stworzonym tłem rysujemy właściwy napis:
draw.text((7, 20), godz +":"+ min +":"+ sec, fill=selfzolty, font=self.pixel)
Używamy domyślnej funkcji draw.text justującej do lewej. Składamy godziny, minuty i sekundy, oddzielając je dwukropkami. Zazwyczaj dwukropki migają z pulsacją 1s, ale nie wiem jak osiągnąć taktowanie 0,5s na wł/wył.
Do dyspozycji mamy trzy funkcje wypisujące tekst o niestety różnej kolejności argumentów. Oprócz tej jest justująca do prawej (czy raczej justująca pole na stałą ilość znaków do lewej a tekst w nim do prawej) i centrująca napis. Wszystkie funkcje naszego autorstwa są do wglądu w pliku:
python\local\abstractscreenrenderer.py. Jeśli napiszecie coś mającego uniwersalny potencjał, warto rozważyć dodanie funkcji tam, by nie dublować w każdym skrypcie tego samego.
def print_fixed_with(self, draw, text, start_point, character_count, font, color, correction=0):
rozmiar_osemki = font.getsize("8")[0]
start_point_tmp = start_point[0]
for znak in range(len(text), character_count):
start_point_tmp += rozmiar_osemki-correction
for znak in range(0,len(text)):
rozmiar_act = font.getsize(text[znak])[0]
draw.text((start_point_tmp + rozmiar_osemki - rozmiar_act, start_point[1]), text[znak], font=font, fill=color)
start_point_tmp += rozmiar_osemki-correction
def print_center(self, draw, text, X, Y, font, color):
w = draw.textsize(text, font)
draw.text(((X-w[0]/2),(Y-w[1]/2)), text, font=font, fill=color)
Prędkościomierz:W metodzie render (musi odczytywać prędkość co klatkę), odczytujemy wartość prędkości liniowej pojazdu (nie łapie poślizgu zestawów niestety; może kiedyś zmienimy).
speed = float(state['velocity'])
Pod zmienną lokalną "speed" podstawiamy wartość wysłaną przez moduł fizyki jako zmiennoprzecinkową float.
Umieszczamy ją jako napis, podobnie jak z zegarkiem.
draw.text((21, 96), '%d' % speed, fill=self.zolty, font=self.pixel)
"%d"-formatowanie; tylko część całkowita. Zamiast tego można by skonwertować ją do int.
Pasek procentu prędkości:Musimy ograniczyć odczyt prędkości by nam nie wychodził poza zakres jak ktoś zacznie majstrować przy lokomotywie. SP32 ma Vmax=100km/h i taki zakres przyjmę, ze względu na brak wyskalowania prędkościomierza na ekranie.
if speed > 100:
speed = 100
Gdy zmienna przekracza 100, ucinamy do setki.
Mierzymy nasz pasek na maksymalnym wypełnieniu. Nie mam zdjęcia i nie wiem gdzie on się kończy, ale by się komponował na ekranie przyjmę następujące wartości:
Zero prostokąta (lewy górny róg): (17,150)
Rozmiar: (108,12)
Czyli rogi będą we współrzędnych: (17,150),(17,162),(125,150),(125,162)
Wzór na długość paska zależnie od prędkości, to:
x2=x1+(V/Vmax*dx)
Podstawiamy nasze dane i mamy:
x2=17+(speed/100*108)=17+1.08*speed
Rysujemy prostokąt którego lewa krawędź będzie w x1=17 a prawa w x2. Dla V=0 x2=x1 czyli pasek jest jednostkowy. Dla V=Vmax wypełnia cały zakres do x2=125. Podajemy dwa przeciwległe narożniki, poczynając od tego o mniejszych wartościach współrzędnych.
x2=17+1.08*speed
draw.rectangle(((17,150),(x2,162)), fill=self.zolty)
Prąd trakcyjny i pasek siły trakcyjnej:Pasek siły trakcyjnej wymagał zmian w exe, stąd będzie działał tylko na 472+. Dodawanie nowych zmiennych jest banalne. Wystarczy odszukać ją w którymś z modułów pojazdu (mover/dynobj/train) i przekazać do pythonowego słownika w PyObject *TTrain::GetTrainState().
Siłę trakcyjną pobieramy jako "tractionforce" i wrzucamy jako pasek analogicznie do prędkości. Wcześniej musimy sobie zmierzyć siłę maksymalną, ale że mamy ją na ekranach diagnostycznych F1/F2, to nie jest to problemem. W innym przypadku można wyprowadzić tymczasowo wartość jako tekst na ekran pythonowy. Pomaga to w debugowaniu gdy dzieją się cuda, zwłaszcza przy implementacji nowych odczytów.
Prąd trakcyjny jest bardziej problematyczny. Mamy zmienną "im", ale w dieselelectric podaje ona prąd na wyjściu prądnicy głównej. Dokładnie to jest wyprowadzane na amperomierz analogowy. Oprócz prądu trakcyjnego mamy w nim prąd ogrzewania. Mamy dwie opcje. Możemy wbić się w funkcję liczącą ten prąd w mover.pas i dodać zmienną pomocniczą, łapiącą prąd przed dodaniem do niego prądu ogrzewania i to ją wyprowadzić na ekran. Wymaga to rekompilacji movera, co jest odrobinę problematyczne, przez błędnie generujące się nagłówki i gryzłoby się z przepisywaniem go na cpp. Druga opcja, to policzyć prąd ogrzewania w pythonie i odjąć go od odczytanego prądu całkowitego. Nie będę się w to zagłębiał, nie wiem czy ma on dostępne wszystkie potrzebne dane, bo nie to jest istotą tworzenia ekranu.
Całość kodu:from PIL import ImageDraw, ImageFont, Image #Ładujemy biblioteki
class ekranik(abstractscreenrenderer): #klasa renderera
def __init__(self,lookup_path,name,cab): #inicjalizacja stałych
self.pixel = ImageFont.truetype(lookup_path + "Alterebro Pixel Font Regular.ttf", 68) #definicja czcionki na napisy
self.zolty = (193,164,50) #kolor na napisy
def _render(self, state): #pętla rendera
#czas
if state['hours']<10: #dodaje zero przed liczbą gdy ma tylko jedną cyfrę
godz = "0" + str(state['hours'])
else:
godz = str(state['hours']) #zapisuje do zmiennej "godz" liczbę godzin jako string
if state['minutes']<10:
min = "0" + str(state['minutes'])
else:
min = str(state['minutes'])
if state['seconds']<10:
sec = "0" +str(state['seconds'])
else:
sec = str(state['seconds'])
obrazek=Image.new(RGB, (256,256), color=(57,57,57)) #tworzymy nowy obrazek tła
draw=ImageDraw.Draw(obrazek) #ładujemy go do edycji
draw.text((7, 20), godz +":"+ min +":"+ sec, fill=self.zolty, font=self.pixel) #napis-zegarek
#prędkość
speed = float(state['velocity']) #odczyt prędkości
draw.text((21, 96), '%d' % speed, fill=self.zolty, font=self.pixel) #napis-prędkość
if speed > 100: #ucięcie prędkości jakby wyszła poza zakres
speed = 100
x2=17+1.08*speed #długość paska prędkości jako procent Vmax x2=17+(speed/100*108)
draw.rectangle(((17,150),(x2,162)), fill=self.zolty) #prostokąt procentu prędkości
return obrazek #wysyłamy finalny obrazek do bufora
Inne funkcjonalności:Włączenie wraz z baterią:Całość rendera wrzucamy w warunek na stan 'battery' a na tło po którym rysujemy dajemy wygaszony ekran, możliwie ciemny, bo musi mieć selfillum=true w materiale.
Mierniki kołowe:Czyli coś w stylu siłomierza traxxa. Figury ograniczonej krzywymi nie możemy narysować o ile mi wiadomo. Obszedłem to przez rysowanie wycinku koła i zakrycie go prostokątną maską w kolorze tła z przezroczystym wycięciem na część która ma być widoczna. Uwaga na błąd powodujący osiąganie przez wartość fizyczną liczby niezgodnej z argumentem wypełnienia wycinka koła. Odsyłam do traxxa, bo jest ona przepuszczana przez gotową funkcję filtrującą, której nie rozumiem do końca.
Mierniki obrotowe:Tu wymagane są podstawy geometrii analitycznej. Liczymy wskazówkę w stanie "na dwunastą" jako krzywą łamaną zdefiniowaną n punktów. Potem Każdy z tych punktów wyrażamy we współrzędnych biegunowych z zerem w osi obrotu naszej wskazówki. Za amplitudę przyjmujemy naszą wartość fizyczną po znormalizowaniu do zakresu miernika i skompensowaniu obrotem w lewo od położenia pionowego do zera na skali miernika. Przeliczamy to na współrzędne kartezjańskie i wyświetlamy. Uwaga, w istniejących ekranach mogą być błędy w zastosowanych funkcjach trygonometrycznych, skutkujące złym znakiem na wyjściu, co zostało zamaskowane mnożeniem wszystkiego przez -1. Zamiast kopiować kod, warto ustawić je prawidłowo.