Gdy coś w kodzie Verse nie działa zgodnie z oczekiwaniami, czasami trudno jest ustalić, co poszło nie tak. Przykładowe problemy, jakie możesz napotkać:
Błędy w czasie wykonywania.
Kod wykonywany w niewłaściwej kolejności.
Procesy trwają dłużej niż powinny.
Każdy z nich może spowodować nieoczekiwane zachowanie kodu, a w konsekwencji problemy w trakcie rozgrywki. Diagnozowanie problemów w kodzie nazywa się debugowaniem. Istnieje kilka różnych rozwiązań, które można zastosować, aby naprawić i zoptymalizować kod.
Błędy w czasie wykonywania Verse
Kod Verse jest analizowany zarówno podczas pisania go na serwerze języka, jak i podczas kompilowania z poziomu edytora lub Visual Studio Code. Jednak ta analiza semantyczna sama w sobie nie jest w stanie wychwycić wszystkich możliwych problemów, jakie możesz napotkać. Gdy kod działa w czasie wykonywania, może spowodować wystąpienie błędów w czasie wykonywania. Spowodują one zaprzestanie dalszego wykonywania kodu Verse, przez co rozegranie przygody może być niemożliwe.
Przypuśćmy na przykład, że masz kod Verse, który wykonuje następujące czynności:
# Has the suspends specifier, so can be called in a loop expression.
SuspendsFunction()<suspends>:void={}
# Calls SuspendFunction forever without breaking or returning,
# causing a runtime error due to an infinite loop.
CausesInfiniteLoop()<suspends>:void=
loop:
SuspendsFunction()Funkcja CausesInfiniteLoop() nie spowodowałaby żadnych błędów w kompilatorze Verse, a program zostałby pomyślnie skompilowany. Jeśli jednak wywołasz funkcję CausesInfiniteLoop() w czasie wykonywania, uruchomi ona pętlę nieskończoną, a tym samym spowoduje wystąpienie błędu w czasie wykonywania.
Aby zapoznać się z błędami w czasie wykonywania, które wystąpiły w grze, przejdź do portalu Content Service. Znajdziesz tam listę wszystkich swoich projektów, zarówno opublikowanych, jak i nieopublikowanych. W przypadku każdego projektu masz dostęp do karty Verse z listą kategorii błędów w czasie wykonywania, które wystąpiły w projekcie. Możesz też sprawdzić stos wywołań Verse, w którym dany błąd został zgłoszony, co pozwoli uzyskać więcej szczegółów na temat tego, co mogło pójść nie tak. Raporty o błędach są przechowywane przez okres do 30 dni.
Pamiętaj, że jest to nowa funkcja na wczesnym etapie prac rozwojowych, a sposób jej działania może ulec zmianie w przyszłych wersjach UEFN i Verse.
Profilowanie wolno działającego kodu
Jeśli kod działa wolniej niż oczekiwano, można go przetestować za pomocą wyrażenia profile. Wyrażenie profile informuje, jak długo trwa uruchomienie określonego fragmentu kodu, i może pomóc w zidentyfikowaniu wolno działających bloków kodu i ich optymalizacji. Wyobraźmy sobie na przykład, że chcemy sprawdzić, czy tablica zawiera konkretną liczbę, i zwrócić indeks w miejscu jej występowania. Można to zrobić, wykonując iterację po tablicy i sprawdzając, czy poszczególne liczby są zgodne z poszukiwaną.
# An array of test numbers.
TestNumbers:[]int = array{1,2,3,4,5}
# Runs when the device is started in a running game
OnBegin<override>()<suspends>:void=
# Find if the number exists in the TestNumbers array by iterating
# through each element and checking if it matches.
for:
Index -> Number:TestNumbers
Jest to jednak mało wydajny kod, ponieważ wymaga sprawdzania każdej liczby w tablicy pod kątem zgodności. Prowadzi to do mało wydajnej złożoności czasowej, ponieważ sprawdzanie listy będzie kontynuowane, nawet jeśli poszukiwany element zostanie znaleziony. Zamiast tego można użyć funkcji Find[], aby sprawdzić, czy tablica zawiera szukaną liczbę, a następnie ją zwrócić. Z uwagi na to, że funkcja Find[] zwraca wynik od razu po znalezieniu elementu, zostanie wykonana tym szybciej, im wcześniej na liście będzie znajdował się poszukiwany element. Jeśli do przetestowania obu fragmentów kodu użyjesz wyrażenia profile, zauważysz, że w tym przypadku kod używający funkcji Find[] powoduje skrócenie czasu wykonywania.
# Runs when the device is started in a running game
OnBegin<override>()<suspends>:void=
# Find if the number exists in the TestNumbers array by iterating
# through each element and checking if it matches.
profile("Finding a number by checking each array element"):
for:
Index -> Number:TestNumbers
Number = 4
do:
Te niewielkie różnice w czasie wykonywania stają się większe, im więcej elementów musi obejmować iteracja. Każde wyrażenie wykonywane podczas iteracji po dużej liście zwiększa złożoność czasową, zwłaszcza gdy tablice rozrastają się do setek, a nawet tysięcy elementów. W przypadku skalowania przygody pod kątem coraz większej liczby graczy użyj wyrażenia profile, aby znaleźć kluczowe obszary powodujące spowolnienie i rozwiązać związane z nimi problemy.
Rejestratory i dane wyjściowe rejestrowania
Domyślnie, gdy wywołasz funkcję Print() w kodzie Verse w celu wydrukowania komunikatu, komunikat ten jest zapisywany w specjalnym dzienniku Print. Drukowane komunikaty pojawiają się na ekranie w trakcie gry, w dzienniku w trakcie gry oraz w dzienniku wyjściowym w UEFN.
Gdy drukujesz komunikat przy użyciu funkcji Print(), komunikat pojawia się w dzienniku wyjściowym, na karcie Dziennik w trakcie gry i na ekranie w trakcie gry.
Jednak w wielu przypadkach możesz nie chcieć, aby komunikaty pojawiały się na ekranie w trakcie gry. Komunikaty możesz wykorzystywać do śledzenia momentów, w których coś się dzieje, na przykład następuje uruchomienie zdarzenia lub upływa określona ilość czasu, albo do sygnalizowania problemów z kodem. Wiele komunikatów w trakcie rozgrywki może rozpraszać uwagę, zwłaszcza jeśli nie przekazują graczowi żadnych istotnych informacji.
Aby rozwiązać ten problem, możesz użyć rejestratora. Rejestrator to specjalna klasa, która umożliwia drukowanie komunikatów bezpośrednio do dziennika wyjściowego i na karcie Dziennik bez wyświetlania ich na ekranie.
Rejestratory
Aby zbudować rejestrator, najpierw należy utworzyć kanał dziennika. Każdy rejestrator zapisuje komunikaty w dzienniku wyjściowym, jednak ustalenie, który komunikat pochodzi z którego rejestratora, może być trudne. Kanały dziennika dodają nazwę kanału dziennika na początku komunikatu, aby ułatwić sprawdzenie, który rejestrator wysłał komunikat. Kanały dziennika są deklarowane w zakresie modułu, podczas gdy rejestratory są deklarowane w klasach lub funkcjach. Poniżej przedstawiono przykład deklarowania kanału dziennika w module, a następnie przykład deklarowania i wywoływania rejestratora wewnątrz urządzenia Verse.
using { /Fortnite.com/Devices }
using { /Verse.org/Simulation }
using { /UnrealEngine.com/Temporary/Diagnostics }
# A log channel for the debugging_tester class.
# Log channels declared at module scope can be used by any class.
debugging_tester_log := class(log_channel){}
# A Verse-authored creative device that can be placed in a level
debugging_tester := class(creative_device):
Gdy drukujesz komunikat przy użyciu funkcji Print() rejestratora, komunikat pojawia się w dzienniku wyjściowym i na karcie Dziennik w trakcie gry.
Poziomy dzienników
Oprócz kanałów można również zdefiniować domyślny poziom dziennika, do którego rejestrator będzie drukował dane. Dostępnych jest pięć poziomów, a każdy z nich ma odrębne właściwości:
| Poziom dziennika | Drukuje do | Właściwości specjalne |
|---|---|---|
Debugowanie | Dziennik w trakcie gry | N/A |
Szczegółowo | Dziennik w trakcie gry | N/A |
Normalny | Dziennik w trakcie gry, dziennik wyjściowy | N/A |
Ostrzeżenie | Dziennik w trakcie gry, dziennik wyjściowy | Tekst jest w kolorze żółtym |
Błąd | Dziennik w trakcie gry, dziennik wyjściowy | Tekst jest w kolorze czerwonym |
Gdy tworzysz rejestrator, domyślnie przyjmuje on poziom dziennika Normal (normalny). Poziom rejestratora można zmienić podczas tworzenia rejestratora lub można określić podczas wywoływania funkcji Print().
# A logger local to the debugging_tester class. By default, this prints
# to log_level.Normal.
Logger:log = log{Channel := debugging_tester_log}
# A logger with log_level.Debug as the default log channel.
DebugLogger:log = log{Channel := debugging_tester_log, DefaultLevel := log_level.Debug}
# Runs when the device is started in a running game
OnBegin<override>()<suspends>:void=
W powyższym przykładzie Logger używa domyślnie kanału dziennika Normal (normalny), podczas gdy DebugLogger używa domyślnie kanału dziennika Debug (debugowanie). Każdy rejestrator może drukować dane do dziennika dowolnego poziomu dzięki podaniu wartości log_level podczas wywoływania funkcji Print().
Rezultaty stosowania rejestratora do drukowania danych na różnych poziomach dziennika. Pamiętaj, że log_level.Debug i log_level.Verbose nie powodują drukowania danych do dziennika w trakcie gry, a jedynie do dziennika wyjściowego UEFN.
Drukowanie stosu wywołań
Stos wywołań śledzi listę wywołań funkcji, które doprowadziły do bieżącego zakresu. Jest to rodzaj skumulowanego zestawu instrukcji, których używa kod, aby wiedzieć, dokąd ma wrócić, gdy zakończy się wykonywanie bieżącej procedury. Stos wywołań z dowolnego rejestratora można drukować za pomocą funkcji PrintCallStack(). Weźmy na przykład następujący kod:
# A logger local to the debugging_tester class. By default, this prints
# to log_level.Normal.
Logger:log = log{Channel := debugging_tester_log}
# Runs when the device is started in a running game
OnBegin<override>()<suspends>:void=
# Move into the first function, and print the call stack after a few levels.
LevelOne()
Powyższy kod w OnBegin() wywołuje LevelOne(), aby przejść do pierwszej funkcji. Następnie funkcja LevelOne() wywołuje funkcję LevelTwo(), która wywołuje funkcję LevelThree() wywołującą funkcję Logger.PrintCallStack() w celu wyświetlenia bieżącego stosu wywołań. Najnowsze wywołanie będzie znajdować się na szczycie stosu, dlatego jako pierwsza zostanie wydrukowana funkcja LevelThree(). Dalej kolejność wygląda następująco: LevelTwo(), LevelOne() i OnBegin().
Gdy w kodzie coś jest nie tak, wyświetlenie stosu wywołań pozwala dowiedzieć się, jakie wywołania doprowadziły do danego punktu. Ułatwia to przeglądanie struktury kodu w trakcie jego wykonywania i umożliwia wyizolowanie poszczególnych śladów w stosie w przypadku projektów o dużej gęstości kodu.
Wizualizacja danych gry za pomocą rysunku do debugowania
Innym sposobem debugowania różnych funkcji na potrzeby tworzonych przygód jest użycie interfejsu API rysunku do debugowania. Pozwala on tworzyć kształty do debugowania w celu wizualizowania danych gry. Oto kilka przykładów:
Linia wzroku strażnika.
Odległość, na jaką przenośnik rekwizytów przeniesie obiekt.
Zasięg wytłumiania odtwarzacza dźwięku.
Możesz użyć tych kształtów do debugowania, aby precyzyjnie dostosować swoją przygodę bez uwidaczniania tych danych w opublikowanych przygodach. Aby dowiedzieć się więcej, patrz: Rysunek do debugowania w Verse.
Optymalizowanie i synchronizowanie przy użyciu współbieżności
Współbieżność jest istotą języka programowania Verse i bogatym w możliwości narzędziem umożliwiającym uatrakcyjnianie tworzonych przygód. Dzięki współbieżności jedno urządzenie Verse może wykonywać wiele operacji jednocześnie. Pozwala to pisać bardziej elastyczny, zwięzły kod i ograniczyć liczbę urządzeń używanych w poziomie. Współbieżność to świetne narzędzie do optymalizacji, a znalezienie sposobów na wykorzystanie kodu asynchronicznego do obsługi wielu zadań jednocześnie to świetny rozwiązanie pozwalające przyspieszyć wykonywanie programów i uporać się z problemami związanymi z synchronizacją czasową.
Tworzenie kontekstów asynchronicznych za pomocą wyrażenia spawn
Wyrażenie spawn rozpoczyna wyrażenie asynchroniczne z dowolnego kontekstu, co jednocześnie umożliwia natychmiastowe wykonanie następujących po nim wyrażeń. Dzięki temu można uruchamiać wiele zadań w tym samym czasie na tym samym urządzeniu, bez konieczności tworzenia nowych plików Verse dla każdego z nich. Rozważmy na przykład scenariusz, w którym kod co sekundę monitoruje zdrowie każdego gracza. Jeśli zdrowie gracza spadnie poniżej określonej wartości, gracz zostanie w niewielkim stopniu uleczony. Potem chcesz wykonać kod odpowiedzialny za realizację innego zadania. Urządzenie, które implementuje ten kod, może wyglądać mniej więcej tak:
# A Verse-authored creative device that can be placed in a level
healing_device := class(creative_device):
# Runs when the device is started in a running game
OnBegin<override>()<suspends>:void=
AllPlayers:[]agent = GetPlayspace().GetPlayers()
# Every second, check each player. If the player has less than half health,
# heal them by a small amount.
Jednak pętla ta jest wykonywana w nieskończoność i nigdy nie dochodzi do jej przerwania, dlatego następujący po niej kod nigdy nie zostanie wykonany. Jest to projekt ograniczający, ponieważ urządzenie utyka na wykonywaniu wyrażenia loop. Aby umożliwić urządzeniu wykonywanie wielu czynności jednocześnie i współbieżne wykonywanie kodu, możesz przenieść kod loop do funkcji asynchronicznej i spawnować go podczas wykonywania funkcji OnBegin().
# Runs when the device is started in a running game
OnBegin<override>()<suspends>:void=
# Use the spawn expression to run HealMonitor() asynchronously.
spawn{HealMonitor()}
# The code after this executes immediately.
Print("This code keeps going while the spawned expression executes")
HealMonitor(Players:[]agent)<suspends>:void=
Jest to ulepszenie, ponieważ urządzenie może teraz uruchamiać inny kod w trakcie wykonywania funkcji HealMonitor(). Jednak funkcja wciąż musi wykonać pętlę po poszczególnych graczach, co może powodować problemy z synchronizacją czasu, gdy w przygodzie uczestniczy wielu graczy. Załóżmy, że chcesz przyznać każdemu graczowi punkty w oparciu o jego PZ albo sprawdzić, czy gracz posiada dany przedmiot. Dołączenie dodatkowej logiki dla każdego gracza w wyrażeniu for zwiększa złożoność czasową tej funkcji i przy wystarczającej liczbie graczy jeden gracz może nie zostać uleczony na czas, jeśli odniesie obrażenia wynikające z problemu z synchronizacją czasową.
Zamiast wykonywania pętli po poszczególnych graczach i sprawdzania każdego z nich z osobna, można dodatkowo zoptymalizować ten kod, spawnując instancję funkcji dla każdego gracza. To oznacza, że jedna funkcja może monitorować pojedynczego gracza, dzięki czemu kod nie musi sprawdzać każdego gracza z osobna, zanim pętka powróci do tego, który wymaga leczenia. Wykorzystanie wyrażeń współbieżności, takich jak spawn, może sprawić, że kod będzie bardziej wydajny i elastyczny, a pozostała część bazy kodu zostanie uwolniona do wykonywania innych zadań.
# Runs when the device is started in a running game
OnBegin<override>()<suspends>:void=
AllPlayers := GetPlayspace().GetPlayers()
# Spawn an instance of the HealMonitor() function for each player.
for:
Player:AllPlayers
do:
# Use the spawn expression to run HealMonitorPerPlayer() asynchronously.
Użycie wyrażenia spawn w wyrażeniu loop może spowodować niepożądane zachowanie, jeśli wyrażenie zostanie przygotowane błędnie. Na przykład działanie funkcji HealMonitorPerPlayer() nigdy nie jest przerywane, dlatego ten kod będzie spawnować nieskończoną liczbę funkcji asynchronicznych, dopóki nie wystąpi błąd w czasie wykonywania.
# Spawn an instance of the HealMonitor() function for each player, looping forever.
# This will cause a runtime error as the number of asynchronous functions infinitely increases.
loop:
for:
Player:AllPlayers
do:
spawn{HealMonitorPerPlayer(Player)}
Sleep(0.0)Sterowanie synchronizacją czasową za pomocą zdarzeń
Prawidłowe zsynchronizowanie wszystkich części kodu bywa trudne, zwłaszcza w dużych przygodach wieloosobowych, w których równocześnie wykonywanych jest wiele skryptów. Różne części kodu mogą być zależne od odmiennych funkcji lub skryptów wykonywanych w ustalonej kolejności, a to w przypadku braku ścisłej kontroli może powodować problemy z synchronizacją między nimi. Przyjrzyjmy się na przykład poniższej funkcji, która odlicza przez pewien czas, a następnie przyznaje przekazanemu do niej graczowi punkty, jeśli liczba jego PZ jest większa niż wartość progowa.
CountdownScore(Player:agent)<suspends>:void=
# Wait for some amount of time, then award each player whose HP is above the threshold some score.
Sleep(CountdownTime)
if:
Character := Player.GetFortCharacter[]
PlayerHP := Character.GetHealth()
PlayerHP >= HPThreshold
then:
ScoreManager.Activate(Player)Ta funkcja ma modyfikator <suspends>, dlatego możesz uruchomić jej instancję w sposób asynchroniczny dla każdego gracza za pomocą funkcji spawn(). Jednak musisz zagwarantować, że każdy inny kod, który polega na tej funkcji, zawsze będzie uruchamiany dopiero po zakończeniu jej działania. Istnieje możliwość drukowania informacji o każdym graczu, który zdobył punkt po zakończeniu działania funkcji CountdownScore(). W tym celu w OnBegin(), wywołaj Sleep() tak, aby czekać tyle samo czasu, ile zajmuje wykonanie CountdownScore(). Jednak może to powodować problemy z synchronizacją czasową, gdy gra jest uruchomiona i wprowadza nową zmienną, którą trzeba aktualizować za każdym razem, gdy wprowadzasz zmiany w kodzie. Zamiast tego możesz utworzyć zdarzenia niestandardowe i w odniesieniu do nich wywołać Await(), aby ściśle kontrolować kolejność zdarzeń w kodzie.
# Custom event to signal when the countdown finishes.
CountdownCompleteEvent:event() = event(){}
# Runs when the device is started in a running game
OnBegin<override>()<suspends>:void=
AllPlayers := GetPlayspace().GetPlayers()
# Spawn a CountdownScore function for each player
for:
Kod oczekuje teraz na zasygnalizowanie zdarzenia CountdownCompletedEvent(), więc wynik każdego gracza zostanie sprawdzony dopiero po zakończeniu wykonywania CountdownScore(). Wiele urządzeń ma wbudowane zdarzenia, w przypadku których można wywoływać funkcję Await(), aby sterować synchronizacją czasową kodu. Wykorzystywanie tych zdarzeń razem z własnymi zdarzeniami niestandardowymi pozwala tworzyć złożone pętle gry z kilkoma ruchomymi częściami. Na przykład szablon pakietu startowego Verse używa kilku zdarzeń niestandardowych do sterowania ruchem postaci, aktualizowania UI i zarządzania ogólną pętlą gry między planszami.
Obsługiwanie wielu wyrażeń za pomocą wyrażeń sync, race i rush
Wyrażenia sync, race i rush umożliwiają równoczesne uruchamianie wielu wyrażeń asynchronicznych, a poza tym można wykonywać różne funkcje po zakończeniu wykonywania tych wyrażeń. Wykorzystując każdą z tych możliwości, można ściśle kontrolować czas życia każdego z wyrażeń asynchronicznych, czego efektem jest bardziej dynamiczny kod, który jest w stanie obsłużyć wiele różnych sytuacji.
Weźmy na przykład wyrażenie rush. To wyrażenie uruchamia współbieżnie wiele wyrażeń asynchronicznych, ale zwraca tylko wartość wyrażenia, które kończy się jako pierwsze. Przypuśćmy, że masz minigrę, w której drużyny muszą wykonać jakieś zadanie, a drużyna, która ukończy zadanie jako pierwsza, otrzymuje wzmocnienie pozwalające przeszkadzać innym graczom, gdy starają się również skończyć grę. Można napisać skomplikowaną logikę synchronizacji czasu, aby śledzić ukończenie zadania przez każdą drużynę, lub użyć wyrażenia rush. Wyrażenie zwraca wartość pierwszego wyrażenia asynchronicznego do zakończenia, dlatego zwróci zwycięską drużynę, jednocześnie zezwalając na wykonywanie kodu, który obsługuje inne drużyny.
WinningTeam := rush:
# All three async functions start at the same time.
RushToFinish(TeamOne)
RushToFinish(TeamTwo)
RushToFinish(TeamThree)
# The next expression is called immediately when any of the async functions complete.
GrantPowerup(WinnerTeam)Wyrażenie race podlega tym samym regułom, z tą różnicą, że po zakończeniu wyrażenia asynchronicznego pozostałe wyrażenia są anulowane. Pozwala to na ścisłą kontrolę czasu życia wielu wyrażeń asynchronicznych jednocześnie. Można nawet połączyć to wyrażenie z wyrażeniem sleep(), aby ograniczyć ilość czasu, przez jaki wyrażenie ma być wykonywane. Rozważmy przykład użycia wyrażenia rush, w którym tym razem chcesz, aby minigra zakończyła się natychmiast po zwycięstwie drużyny. Chcesz również dodać licznik czasu, aby minigra nie trwała w nieskończoność. Wyrażenie race umożliwia wykonywanie obu tych czynności bez konieczności używania zdarzeń lub innych narzędzi współbieżności w celu uzyskania informacji o tym, kiedy należy anulować wyrażenia przegrywające „wyścig” obsługiwany przez wyrażenie race.
WinningTeam := race:
# All four async functions start at the same time.
RaceToFinish(TeamOne)
RaceToFinish(TeamTwo)
RaceToFinish(TeamThree)
Sleep(TimeLimit)
# The next expression is called immediately when any of the async functions complete. Any other async functions are canceled.
GrantPowerup(WinnerTeam)Wreszcie wyrażenie sync umożliwia oczekiwanie na zakończenie wykonywania wielu wyrażeń. To gwarantuje, że każde z nich zostanie ukończone przed kontynuacją. Wyrażenie sync zwraca krotkę zawierającą wyniki każdego z wyrażeń asynchronicznych, dlatego możesz zakończyć działanie wszystkich swoich wyrażeń i ocenić dane uzyskane z każdego z nich z osobna. Wracając do przykładu minigry. Załóżmy, że chcesz przyznać wzmocnienia każdej drużynie zgodnie z wynikami, jakie uzyskała w minigrze. Do tego właśnie służy wyrażenie sync.
TeamResults := sync:
# All three async functions start at the same time.
WaitForFinish(TeamOne)
WaitForFinish(TeamTwo)
WaitForFinish(TeamThree)
# The next expression is called only when all of the async expressions complete.
GrantPowerups(TeamResults)Jeśli chcesz uruchomić wyrażenie asynchroniczne w odniesieniu do wielu elementów tablicy, możesz użyć przydatnej funkcji ArraySync(), aby zagwarantować ich synchronizację.
Każde z tych wyrażeń współbieżności jest samo w sobie potężnym narzędziem, a poznając sposób ich łączenia i wykorzystywania, możesz napisać kod, który poradzi sobie w każdej sytuacji. Przyjrzyj się przykładowi z szablonu Wyścig szosowy z persystencją Verse, który łączy wiele wyrażeń współbieżności, aby odtworzyć każdemu graczowi wstęp przed wyścigiem, a także anulować odtwarzanie, gdy gracz opuści grę w trakcie wstępu. Ten przykład pokazuje, jak można wykorzystywać współbieżność na wiele sposobów i tworzyć elastyczny kod, który dynamicznie reaguje na różne zdarzenia.
# Wait for the player's intro start and display their info.
# Cancel the wait if they leave.
WaitForPlayerIntro(Player:agent, StartOrder:int)<suspends>:void=
var IntroCounter:int = 0
race:
# Waiting for this player to finish the race and then record the finish.
loop:
sync: