Dziennik Twórców Stellaris #182: O problemach przy pisaniu skryptów i jak ich uniknąć

Siema wszystkim! Jestem Caligula, jednym z content designerów Stellaris, co znaczy, że robię przeróżne rzeczy związane z pisaniem wydarzeń i skryptów – „pisanie skryptów” to według nas tworzenie podobnych rzeczy co programiści, ale bez zmiany kodu źródłowego. Innymi słowy, robię to, co robią moderzy (chociaż mam znaczącą przewagę, bo mogę również zajrzeć do kodu źródłowego i zmienić go w razie potrzeby). Każdy content designer też ma swoją niszę i moją jest taka, że kiedy wyjątkowo skomplikowana funkcjonalność wymaga napisania skryptów (albo sprawia kłopoty – Wojna w Niebie nadal mi się śni po nocach), wkraczam ja.

Caligula Caesar (Stellaris Content Designer)

Mamy wiele ekscytujących rzeczy, które przedstawimy w nadchodzących tygodniach i miesiącach, ale na dziś, zainspirowani kilkoma pytaniami zadanymi po ostatnim dzienniku twórców, zamierzam pisać od technicznej strony o tworzeniu skryptów dla moderów i początkujących moderów, szczególnie z myślą o tym, co może powodować problemy z wydajnością i jak uniknąć tworzenia złych skryptów.

Język skryptowy Stellaris jest bardzo potężnym narzędziem i wiele można z nim zrobić, ale przede wszystkim uwaga: tylko dlatego, że coś jest możliwe, nie oznacza, że ​​należy to zrobić. Naprawdę nie mogę tego wystarczająco podkreślić, ponieważ (i mówię tutaj z doświadczenia) taka postawa prawie zawsze skończy się zarówno problemami z wydajnością, jak i nieczytelnymi skryptami, których nie będziesz w stanie zrozumieć sześć miesięcy później, gdy zdasz sobie sprawę, ze trzeba coś poprawić. Chociaż należy pamiętać, że robienie czegoś w kodzie jest z definicji szybsze: w kodzie, możesz sprawdzić pojedynczą funkcję i mieć to z głowy, ale jeżeli chcesz, aby była dostępna poprzez skrypt, potrzeba kilku dodatkowych obowiązkowych funkcji, przez które musi przejść, zanim będzie można operować na własnej (sprawdzanie pojedynczych linii kodu w konsoli, sprawdzanie, czy jest w dobrym zakresie itp. itd.) – dlatego niektóre rzeczy są zakodowane na stałe oraz dlatego niektóre nieeleganckie rozwiązania z tym związane mogłyby skończyć się poważnymi problemami. Zatem pierwsze pytanie do rozważenia brzmi: czy naprawdę powinienem to robić?

Ale kogo ja oszukuję, Piszę do tutaj do moderów, więc oczywistością jest, że to zrobicie :D, więc bez zbędnych ceregieli…

Co powoduje problemy z wydajnością?

Za każdym razem, gdy sprawdzasz obiekt lub stosujesz jakiś efekt, obliczenia zajmą bardzo niewielką ilość mocy komputera. Z paroma wyjątkami, które powinny być oszczędnie używane (jeszcze do nich przejdziemy), to jest całkowicie w porządku i w zupełności wystarczające. Problem występuje, gdy sprawdzanie wykonywane jest na bardzo wielu obiektach. W praktyce oznacza to, ze winowajcą są zazwyczaj populacje, ale dodanie czegoś naraz na wszystkie planety w galaktyce też nie jest najlepszym pomysłem.

Pierwszym krokiem, jeżeli to możliwe, jest kontrola skryptu podczas jego działania. Najlepszym sposobem na to jest użycie gdziekolwiek to możliwe w momencie jakiegoś zdarzenia (lub zdarzeń z decyzji itp.) wyzwalacza on_actions, a nie MTTH, a co gorsza, po prostu ustawienie zdarzenia i czekanie na niego każdego dnia. Jeśli potrzebny jest pewien stopień losowości, można uruchomić również ukryte zdarzenie, powiedzmy, za pomocą yearly pulse i wtedy uruchomić właściwe zdarzenie, które chcesz, żeby wydarzyło się z losowym opóźnieniem (sprawdź event action.220).

Oczywiście, nie wszystko w Stellaris to zdarzenia i niestety, wiele rzeczy związane jest z populacją. Przeszłość udowodniła, że job weights i inne wyzwalacze w plikach jobs powodowały niejednokrotnie problemy. Praktyczna zasada – nawet jeśli zrobisz super fajne rzeczy, złym pomysłem jest robienie zbyt skomplikowanych skryptów związanych z jobami. Na przykład, jeśli miałbyś uzależnić jakieś job weight od tego, czy na planecie nie ma bezrobotnych (używając planet = { any_owned_pop = { is_unemployed = yes } }), musiałbyś regularnie sprawdzać każdą populację na planecie, a następnie sprawdzać jeszcze pozostałe np. w dzielnicach. Gdy dojdziesz do późnej fazy gry, z pewnością spowoduje to problemy.

Jak temu zaradzić?

Unikanie zagnieżdżonych pętli, upewnianie się, że zdarzenia są odpowiednio uruchamiane i prowadzisz oszczędne działania w obrębie populacji, gdy tylko to możliwe. Ale można zrobić coś lepszego. Oto moja lista porad dotyczących optymalizacji skryptów:

Zawsze używaj odpowiedniego zakresu

Powiedzmy, że chcesz sprawdzić coś na temat obecnego przywódcy federacji imperiów. Teoretycznie można to zrobić w ten sposób:

any_country = {
            is_in_federation_with = root
            is_federation_leader = yes
            <my_triggers_here> = yes
}

Skrypt obiegnie wszystkie imperia w grze i sprawdzi, czy należą do tej samej federacji co ty, wliczając w to kosmiczne ameby i wstrętnych Pasharti (czy ktokolwiek by chciał, żeby dołączyli do federacji?). Ta grupa jest zdecydowanie nieistotna, więc lepiej byłoby zrobić to w ten sposób:

any_federation_ally = {
            is_federation_leader = yes
            <my_triggers_here> = yes
}

W punktu widzenia kodu, oznacza to, że gra przechodzi z imperium do jej federacji i pobiera listę jej członków, wyłączając bieżące imperium. Następnie sprawdza wyzwalacze z nimi związane. Co prawda jest mniej rzeczy do kontroli, jednak najlepsza wersja byłaby taka:

federation.leader = {
            <my_triggers_here> = yes
}

Ta wersja skryptu trafiłaby bezpośrednio do federacji, a stamtąd bezpośrednio do kodu jej lidera, przy jak najmniejszej liczbie konwersji skryptu na kod i bez konieczności sprawdzania wyzwalaczy dla jakiegokolwiek imperium. Jest też najbardziej czytelny (czytelność i lepsza wydajność bardzo często korelują…).

W wymienionych przypadkach gra w pierwszym sprawdziłaby około 50 imperiów, w drugim 5 i 1 w trzecim – nieźle jak na odrobinę optymalizacji! Korzystając z podobnych rozwiązań, zawsze jest lepiej nie używać czegoś, co musi sprawdzić wszystkie obiekty w galaktyce (zwłaszcza wszystkich populacji lub planet) o ile jest to możliwe. Lepiej działać na przefiltrowanej liście, np. any_planet_within_border zamiast any_planet = { solar_system.owner = { is_same_value = prevprev } } (widziałem, jak się śmiejesz). I fakt, prawie zawsze można sprawdzić any_owned_fleet instead of any_owned_ship.

Innym ważnym ulepszeniem, które dodaliśmy w 2.6, było any_own_species, które mogło zastąpić sprawdzanie any_own_pop (szczególnie te, które sprawdzają cechy itp.), co oznaczało, że mniej obiektów musi być pobieranych (w ksenofobicznym imperium dla any_owned_species to mogą być pojedyncze liczby i tysiące dla any_owned_pop).

Zakres czasami można całkowicie pominąć

Podobnie, jeżeli możesz sprawdzać coś, nie stosując zakresu, będzie to dobrym wyjściem. Tak więc, jeśli ktoś chce sprawdzić, czy planeta ma więcej niż dwie populacje pracujące jako górnicy, można to zrobić na dwa sposoby:

count_owned_pop = {
            count > 2
            limit = {
                has_job = miner
            }
}
num_assigned_jobs = {
            job = miner
            value >= 2
}

Pierwszy pobierze każdego popa na planecie i sprawdzi, czy są górnikami, a następnie ustali, czy jest ich więcej niż 2. Ten drugi sprawdzi liczbę w pamięci podręcznej, którą gra już obliczyła i czy jest większa niż 2, co jest znacznie szybsze.

Niektóre rzeczy są po prostu obciążające

Nie każde sprawdzenie lub efekt są równoważne. Sprawdzanie znaczników lub wartości jest ogólnie proste, a ich zmiana niewiele trudniejsza. Jeśli jednak gra będzie musiała przeliczyć zestaw danych, potrwa to dłużej, ponieważ nie chodzi tylko o wyszukanie liczby, którą już wcześniej znała. Tworzenie nowych rzeczy jest także bardziej obciążające, ponieważ zarówno jest bardziej skomplikowane (efekt create_species, nie żartuję, ma ponad 600 linii kodu w C++), jak i gra będzie musiała ponownie obliczyć wszelkiego rodzaju wartości potrzebne do ich wykonania. Znalezienie wyzwalaczy i efektów, które mogą wymknąć się spod kontroli, może być trochę zawiłe, ale z reguły warto mieć w pamięci następujące sytuacje:
  • Coś, gdzie trzeba stworzyć nowy zakres np. create_country, create_species, modify_species
  • Coś, co wymaga obliczenia lub przeliczenia ścieżki (np. can_access_system trigger, tworzenie nowych hiperlinii, tworzenie nowych systemów gwiezdnych)
  • Coś, co oblicza populacje (np. zmiany w ich stanowiskach pracy)

Jeśli to konieczne…

Czasami trzeba zrobić złe rzeczy. W takich przypadkach najlepiej określić precyzyjnie, gdzie trzeba ich użyć. Gdy gra sprawdza wyzwalacze, np. dla jakiegoś zdarzenia, powinna przestać w momencie, gdy coś zwróci fałsz (ponoć nazywa się to „short-circuit evaluation”), warto więc zrobić coś podobnego:

trigger = {
        has_country_flag = flag_that_narrows_things_down_loads
        <something really horrible here>
    }

Niedawno zrobiłem coś podobnego dla efektu uchodźczych populacji. Poprzednia wersja była trochę szalona (pełną zgrozy skrypt znajdziesz w 01_scripted_triggers_refugees.txt). W sumie sprawdzałby wariację następujących elementów do ośmiu razy:

any_relation = {
            is_country_type = default
            has_communications = prev #relations include countries that have made first contact but not established comms
            NOT = { has_policy_flag = refugees_not_allowed }
            prevprev = { #this ensures Pop scope, as root will not always be pop scope
                OR = { 
                    has_citizenship_type = { type = citizenship_full country = prev }
                    has_citizenship_type = { type = citizenship_caste_system country = prev }
                    AND = {
                        has_citizenship_type = { type = citizenship_limited country = prev }
                        has_citizenship_type = { type = citizenship_caste_system_limited country = prev }
                        prev = { has_policy_flag = refugees_allowed }
                    }
                }
            }
            any_owned_planet = {
                is_under_colonization = no
                is_controlled_by = owner
                has_orbital_bombardment = no
            }
}

Gdzie ogólnie skrypt jest zróżnicowany, kończy się prostym any_own_planet: najpierw próbuje znaleźć naprawdę dobrą planetę, na której populacja mogłaby żyć, potem całkiem dobrą, później przyzwoitą, a na końcu zadowoliliby się jakąkolwiek. Takie działanie jest dość nieefektywne, ponieważ lista krajów, która przyjmuje uchodźców, nie zmienia się pomiędzy każdym sprawdzeniem (aż do 8 razy) skryptu. Moim rozwiązaniem, aby tego uniknąć i uczynienie skryptu bardziej czytelnym, było ustawienie znacznika przed każdym sprawdzaniem, na przykład:

every_relation = {
            limit = {
                has_any_habitability = yes #bare minimum for being a refugee destination
            }
            set_country_flag = valid_refugee_destination_for_@event_target:refugee_pop
}

Sprawdzania musiałyby po prostu odpowiadać na pytania: „Czy imperium ma znacznik? Jeśli tak, czy ma wystarczająco dobrą planetę?”:

has_good_habitability_and_housing = {
    has_country_flag = valid_refugee_destination_for_@event_target:refugee_pop
    any_owned_planet = {
        habitability = { who = event_target:refugee_pop value >= 0.7 }
        free_housing >= 1
        is_under_colonization = no
        is_controlled_by = owner
        has_orbital_bombardment = no                                
    }
}

Można również w podobny sposób użyć if-limits i else (a nawet lepiej, switche, jeśli to możliwe – te są najlepsze dla wydajności) w wyzwalaczach, aby zawęzić sprawdzanie i uczynić rzeczy znacznie bardziej czytelnymi. Niedawno przejrzałem pliki dotyczące praw gatunków i ze względu na dobre praktyki ponownie je przepisałem:

(Przed)

custom_tooltip = {
            fail_text = MACHINE_SPECIES_NOT_MACHINE
            OR = {
                has_trait = trait_mechanical
                has_trait = trait_machine_unit
                from = { has_valid_civic = civic_machine_assimilator }
            }
        }
        custom_tooltip = {
            fail_text = ASSIMILATOR_SPECIES_NOT_CYBORG
            OR = {
                NOT = { from = { has_valid_civic = civic_machine_assimilator } }
                AND = {
                    OR = {
                        has_trait = trait_cybernetic
                        has_trait = trait_machine_unit
                        has_trait = trait_mechanical
                    }
                    from = { has_valid_civic = civic_machine_assimilator }
                }
            }
}

(Po)

 if = {
            limit = {
                from = { NOT = { has_valid_civic = civic_machine_assimilator } }
            }
            custom_tooltip = {
                fail_text = MACHINE_SPECIES_NOT_MACHINE
                OR = {
                    has_trait = trait_mechanical
                    has_trait = trait_machine_unit
                }
            }
        }
        else = {
            custom_tooltip = {
                fail_text = ASSIMILATOR_SPECIES_NOT_CYBORG
                OR = {
                    has_trait = trait_cybernetic
                    has_trait = trait_machine_unit
                    has_trait = trait_mechanical
                }
            }
}

Druga wersja będzie wydajniejsza, ponieważ sprawdza tylko raz (zamiast dwóch) np. czy gatunek ma cechę robota, czy też imperium ma ustrój asymilatora, a także wyzwalacze w drugim niestandardowym tooltipie nie są już co najmniej dziwne. (Usunąłem też wszystkie NAND, ponieważ mózg mi eksplodował).

Szczęśliwego Nowego Roku

Nie mógłbym napisać tego dziennika twórców, nie wspominając o błędzie „Szczęśliwego Nowego Roku”. Właściwie, graliśmy sobie jako twórcy, multiplayer z dość dużą galaktyką i dotarliśmy do późnej fazy gry, a ponieważ wszyscy pracowaliśmy zdalnie na bardzo różnych komputerach i prędkościach połączenia internetowego, wydajność była być może odrobinę kiepska, ale nadal akceptowalna dla większości z nas. Wtedy nagle zauważyliśmy ogromne skoki opóźnienia – 20 sekund i więcej, dokładnie 1 stycznia. Te skoki były tak zauważalne, że zaczęliśmy życzyć sobie szczęśliwego Nowego Roku za każdym razem, gdy gra się zawieszała!

Tak się złożyło, że początek tego opóźnienia zbiegł się, z tym że kilka dużych imperiów zdecydowało się zostać syntetykami i zaczęli wzajemnie się asymilować. Teraz, uświadomiliśmy sobie po fakcie, że udostępnienie asymilacji w kategorii skryptów nie było najlepszym pomysłem… i, że uruchamia się poprzez wydarzenie dla każdego asymilowanego imperium każdego 1 stycznia. Z kolei to wywołało zdarzenia dla każdej z ich planet z mnóstwem populacji i użyły na każdej z nich od 1 do 4 razy efektu modify_species. Zjadło to znaczne zasoby, które odbiły się na wydajności!

Po wypróbowaniu różnych rozwiązań okazało się, że najlepszym rezultatem było najpierw przejrzenie wszystkich posiadanych every_owned_species w zakresie imperium. Następnie sprawdzenie, czy gatunek powinien zostać zasymilowany, a jeśli tak, użycie na nim efektu modified_species, do którego miałby się zasymilować, ustawiając na nim znacznik, który by na niego wskazywał. Następnie, zamiast tworzyć nowy gatunek dla każdej zasymilowanej populacji, skrypt został przepisany na nowo, aby znajdował już istniejący gatunek, którym domyślnie mieliby się stać i po prostu używał na nich change_species. Niestety skrypt nadal był nieczytelny (oszczędzę twoich oczu i nie opublikuję go tutaj), ale w moich testach zmniejszył coroczny tyk o ponad 50%, wszystko dzięki temu, że obciążający efekt (modify_species) jest uruchamiany tak rzadko, jak to możliwe.


To wszystko obecnie ode mnie! Domyślam się, że dla większości z was ten dziennik twórców mógł być nieco za spokojny, ale mam nadzieję, że mimo wszystko był interesujący :). Na koniec, podejrzewam, że bardziej wnikliwi z was przytoczą fragmenty skryptów z gry, które nie spełniają tych wytycznych. Proszę, nie krępujcie się, ponieważ w tej pracy jest niewiele rzeczy bardziej satysfakcjonujących niż stworzenie babola, a następnie naprawienie go!

Źródło: Stellaris Dev Team