Когда что-то в коде Verse не работает должным образом, иногда сложно понять, что пошло не так. Вы можете наблюдать следующее:
Ошибки среды выполнения.
Код выполняется в неправильном порядке.
Процессы занимают больше времени, чем требуется.
Любая из этих ошибок может привести к тому, что код будет работать не так, как надо, что нарушит игровой процесс. Процесс диагностирования проблем в коде называется отладкой, и существует несколько различных решений, которые можно использовать для исправления и оптимизации кода.
Ошибки среды выполнения в Verse
Код Verse анализируется как во время его написания на языковом сервере, так и при его компиляции из редактора или Visual Studio Code. Однако сам по себе семантический анализ не может выявить все возможные проблемы. Когда код выполняется в среде выполнения, могут возникать ошибки среды выполнения. Это приведёт к остановке выполнения всего последующего кода Verse, из-за чего в вашу игру станет невозможно играть.
Для примера представим, что у вас есть код Verse, который выполняет следующее:
# 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()Функция CausesInfiniteLoop() не будет вызывать ошибок в компиляторе Verse, и ваша программа успешно скомпилируется. Однако если вызвать CausesInfiniteLoop() в среде выполнения, то запустится бесконечный цикл, что и приведёт к ошибке среды выполнения.
Чтобы изучить ошибки среды выполнения, возникшие в игре, см. «Портал сервиса управления контентом». Здесь есть список всех ваших проектов, как опубликованных, так и неопубликованных. Для каждого проекта вы сможете открыть вкладку Verse, где будет список категорий ошибок среды выполнения, возникших в проекте. Вы также можете проверить стек вызовов Verse, в котором было сообщено о конкретной ошибке, — так вы получите больше информации о том, что могло пойти не так. Отчёты об ошибках хранятся до 30 дней.
Учтите, что эта функция находится на раннем этапе разработки и принцип её работы может быть изменён в последующих версиях UEFN и Verse.
Профилирование медленного кода
Если код работает медленнее, чем нужно, можно протестировать его с помощью выражения profile. Выражение profile сообщает, сколько времени требуется на выполнение определённого фрагмента кода, и может помочь вам выявить медленные блоки кода и оптимизировать их. Представим, что вы хотите узнать, содержит ли массив определённое число, и вернуть индекс, в котором это число содержится. Это можно сделать, перебрав элементы массива и проверив, совпадает ли число с искомым.
# 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
Однако этот код неэффективен, поскольку вам придётся проверять каждое число в массиве на совпадение. Это приводит к неэффективной временной сложности, поскольку, даже если элемент будет найден, программа продолжит проверку остальных элементов из списка. Вместо этого вы можете использовать функцию Find[], чтобы проверить, содержит ли массив искомое число, и вернуть его. Поскольку Find[] возвращает значение сразу же при нахождении элемента, то чем раньше этот элемент будет в списке, тем быстрее выполнится функция. Если вы используете выражение profile для тестирования обеих частей кода, вы увидите, что в данном случае коду, использующему функцию Find[], требуется меньше времени на выполнение.
# 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:
Эти небольшие различия во времени выполнения усиливаются по мере увеличения количества элементов для итерации. Каждое выражение, которое вы выполняете при итерации по большому списку, добавляет временную сложность, особенно когда ваши массивы разрастаются до сотен или даже тысяч элементов. По мере масштабирования игры, чтобы охватывать всё больше игроков, используйте выражение profile для поиска и обработки ключевых областей, требующих улучшения производительности.
Регистраторы и записи в журнал выходных данных
По умолчанию при вызове Print() в коде Verse сообщение для вывода записывается в специальный журнал Print. Сообщения отображаются на экране в игре, во внутриигровом журнале и в Журнале выходных данных в UEFN.
Когда вы выводите сообщение с помощью функции Print(), оно выводится в журнал выходных данных, на внутриигровой вкладке «Журнал», а также на экран в игре.
Однако во многих случаях выводить сообщения на экран в игре нет необходимости. Сообщения можно использовать для отслеживания различных моментов, например возникновения события или истечения определённого времени, а также для вывода какой-либо ошибки в коде при его выполнении. Большое количество сообщений во время игры может отвлекать, особенно если они не имеют существенного значения для игрока.
Для решения этого можно использовать регистратор. Регистратор — это специальный класс, который позволяет выводить сообщения непосредственно в Журнал выходных данных и на вкладку Журнал без вывода их на экран.
Регистраторы
Чтобы создать регистратор, сначала нужно создать канал записи в журнал. Каждый регистратор выводит сообщения в журнал выходных данных, но может быть сложно определить, какой именно регистратор вывел то или иное сообщение. Каналы записи в журнал добавляют свои названия в начало сообщения, что позволяет легко определить, какой регистратор отправил сообщение. Каналы записи в журнал объявляются в области видимости модуля, а регистраторы объявляются внутри классов или функций. Ниже приведён пример объявления канала записи в журнал в области видимости модуля, а затем объявления и вызова регистратора внутри устройства 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):
Когда вы выводите сообщение с помощью функции Print() регистратора, оно заносится в журнал выходных данных и на внутриигровую вкладку «Журнал».
Уровни журналов
Помимо каналов вы также можете указать уровень журнала по умолчанию, в который регистратор будет отправлять сообщения. Существует пять уровней журналов, каждый со своими свойствами:
| Уровень журнала | Запись в | Специальные свойства |
|---|---|---|
Отладка | Внутриигровой журнал | Н/Д |
Подробно | Внутриигровой журнал | Н/Д |
Нормали | Внутриигровой журнал, Журнал выходных данных | Н/Д |
Внимание | Внутриигровой журнал, Журнал выходных данных | Цвет текста: жёлтый |
Ошибка | Внутриигровой журнал, Журнал выходных данных | Цвет текста: красный |
При создании регистратора по умолчанию задаётся уровень журнала Normal. Вы можете изменить уровень журнала при создании регистратора или указать его при вызове 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=
В примере выше Logger по умолчанию использует канал записи в журнал Normal, а DebugLogger — Debug. Любой регистратор может отправлять сообщения в журнал любого уровня путём указания log_level при вызове Print().
Результаты использования регистратора для вывода сообщений в журнал разных уровней. Обратите внимание, что log_level.Debug и log_level.Verbose выводят сообщения не во внутриигровой журнал, а только в журнал выходных данных UEFN.
Вывод стека вызовов
Стек вызовов отслеживает список вызовов функций, которые привели в текущую область видимости. Он похож на набор инструкций, которые используются кодом для того, чтобы знать, куда вернуться после завершения выполнения текущей подпрограммы. Стек вызовов можно вывести из любого регистратора с помощью функции PrintCallStack(). Для примера возьмём следующий код:
# 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()
Код в OnBegin() выше вызывает LevelOne() для перехода к первой функции. Затем LevelOne() вызывает LevelTwo(), которая вызывает функцию LevelThree(), которая вызывает Logger.PrintCallStack() для вывода текущего стека вызовов. Самый последний вызов будет на вершине стека, поэтому LevelThree() будет выводиться в первую очередь. Затем будут идти LevelTwo(), LevelOne() и OnBegin() (именно в этом порядке).
Если код будет выполняться неверно, вывод стека вызовов очень пригодится при определении вызовов, которые привели к ошибке. Это позволит вам увидеть структуру кода во время его выполнения и изолировать отдельные трассировки стека в проектах со сложным кодом.
Визуализация игровых данных с помощью отладочного рисования
Ещё один способ отладки различных функций вашей игры — это использование API отладочного рисования. Этот API может создавать отладочные формы для визуализации игровых данных. Вот некоторые примеры:
Линия обзора охранника.
Расстояние, на которое устройство перемещения объектов будет перемещать объект.
Расстояние затухания аудиопроигрывателя.
С помощью этих отладочных форм вы можете точно настроить свою игру, не предоставляя эти данные в опубликованную игру. Более подробная информация представлена в разделе «Отладочное рисование в Verse».
Оптимизация и синхронизация времени с одновременным выполнением
Одновременное выполнение лежит в основе языка программирования Verse и является мощным инструментом, позволяющим улучшить игровой процесс. Благодаря этому одно устройство Verse может выполнять несколько операций одновременно. Это позволяет писать более гибкий и компактный код и ограничивать количество устройств на уровне. Одновременное выполнение — это отличный инструмент оптимизации, а поиск способов использования асинхронного кода для одновременного выполнения нескольких задач позволит вам ускорить выполнение программ и устранить проблемы, связанные с распределением времени.
Создание асинхронных контекстов с помощью spawn
Выражение spawn запускает асинхронное выражение из любого контекста, обеспечивая при этом немедленное выполнение последующих выражений. Оно позволяет запускать несколько задач одновременно с одного устройства без необходимости создавать новые файлы Verse для каждой из них. Для примера рассмотрим сценарий с кодом, который каждую секунду отслеживает здоровье каждого игрока. Если здоровье игрока падает ниже определённого значения, нужно немного исцелить его. После этого нужно запустить некоторый код, обрабатывающий другую задачу. Устройство, в котором реализован этот код, может выглядеть примерно так:
# 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.
Однако, поскольку этот цикл выполняется бесконечно и не прерывается, любой код, следующий за ним, не будет выполнен. Этот дизайн является ограничивающим, поскольку устройство зависает, выполняя только выражение loop. Чтобы устройство могло обрабатывать несколько задач одновременно и выполнять код параллельно, можно поместить код loop в асинхронную функцию и активировать его во время 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=
Таким образом, мы оптимизировали код, поскольку теперь устройство может выполнять другой код, параллельно выполняя функцию HealMonitor(). Однако функция всё равно должна перебирать в цикле каждого игрока, и чем больше игроков в игре, тем вероятнее всего возникнут проблемы с синхронизацией времени. К примеру, что если бы нужно было начислять каждому игроку очки с учётом его здоровья или проверять, удерживает ли он предмет? Добавление дополнительной логики для каждого игрока в выражение for увеличивает временную сложность для этой функции, и при достаточно большом количестве игроков один игрок может не получить лечение своевременно в случае получения урона из-за проблем с синхронизацией времени.
Вместо того чтобы перебирать в цикле каждого игрока и проверять их по отдельности, можно ещё больше оптимизировать этот код, создав экземпляр функции для каждого игрока. Это означает, что одна функция может отслеживать одного игрока, благодаря чему коду не нужно проверять каждого игрока, прежде чем вернуться к тому, кому требуется лечение. Использование выражений с одновременным выполнением, таких как spawn, может сделать код более эффективным и гибким, а также высвободить ресурсы для выполнения других задач.
# 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.
Использование выражения spawn внутри блока loop при неверном использовании может привести к нежелательному поведению. Например, поскольку HealMonitorPerPlayer() никогда не завершается, этот код будет создавать бесконечное количество асинхронных функций до тех пор, пока не возникнет ошибка среды выполнения.
# 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)Управление синхронизацией времени с помощью событий
Корректная синхронизация каждой части кода может оказаться затруднительной, особенно в масштабных многопользовательских играх с большим количеством сценариев, выполняемых одновременно. Разные части вашего кода могут зависеть от других функций или сценариев, выполняемых в определённом порядке, и это может создать проблемы во времени между ними при отсутствии строгого контроля. Для примера рассмотрим следующую функцию, которая ведёт обратный отсчёт в течение определённого времени, а затем начисляет переданному в неё игроку некоторые очки, если его здоровье больше порогового значения.
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)Поскольку эта функция имеет модификатор <suspends>, вы можете запускать её экземпляр асинхронно для каждого игрока по отдельности с помощью spawn(). Однако нужно обязательно проверить, чтобы любой другой код, зависящий от этой функции, всегда запускался после её завершения. Что делать, если требуется вывести данные о каждом игроке, который набрал очки после завершения выполнения CountdownScore()? Это можно сделать во время OnBegin(), вызвав Sleep(), чтобы подождать столько же времени, сколько требуется для выполнения CountdownScore(). Однако важно помнить, что это может создать проблемы с синхронизацией времени, когда игра активна. При этом здесь вводится новая переменная, которую вам нужно постоянно обновлять, если вы вдруг захотите внести изменения в свой код. Вместо этого вы можете создавать пользовательские события и вызывать для них Await(), чтобы строго контролировать порядок событий в коде.
# 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:
Поскольку теперь этот код ожидает получения сигнала о CountdownCompletedEvent(), он проверит счёт каждого игрока только после завершения выполнения CountdownScore(). Многие устройства имеют встроенные события, для которых можно вызывать Await(), чтобы управлять временем выполнения кода: используя эти события, вы можете создавать сложные игровые циклы с несколькими частями, положение которых можно изменять. К примеру, Стартовый шаблон Verse использует несколько пользовательских событий для контроля перемещения персонажей, обновления интерфейса и управления общим игровым циклом от начала и до конца.
Обработка нескольких выражений с помощью Sync, Race и Rush
Sync, race и rush позволяют запускать несколько асинхронных выражений одновременно, выполняя различные функции, когда эти выражения завершают выполнение. Используя каждое из них, вы можете чётко контролировать время существования каждого из ваших асинхронных выражений, что позволит создать более динамичный код, способный обрабатывать разные ситуации.
Для примера возьмём выражение rush. Оно запускает одновременно несколько асинхронных выражений, но возвращает только значение выражения, которое завершится первым. Предположим, у вас есть мини-игра, в которой команды должны выполнить какое-либо задание, при этом команда, выполнившая его первой, получает усилитель, который позволяет ей мешать другим игрокам, пока они выполняют задание. Вы можете написать сложную временную логику для отслеживания выполнения задания каждой командой или использовать выражение rush. Поскольку выражение возвращает значение первого завершённого асинхронного выражения, оно вернёт команду-победителя. При этом код, обрабатывающий другие команды, сможет продолжить выполнение.
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)Выражение race следует тем же правилам, за исключением того, что при завершении одного асинхронного выражения другие выражения отменяются. Оно позволяет строго контролировать время существования нескольких асинхронных выражений одномоментно, и вы даже можете совместить его с выражением sleep(), чтобы ограничить время выполнения этого выражения. Рассмотрим пример с rush: на этот раз нам нужно завершить мини-игру немедленно в случае победы одной из команд. Также нужно добавить таймер, чтобы мини-игра не длилась бесконечно. Выражение 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)Наконец, выражение sync позволяет дождаться завершения выполнения нескольких выражений, гарантируя тем самым, что каждое из них завершится, прежде чем код продолжит выполняться. Поскольку выражение sync возвращает кортеж, содержащий результаты каждого из асинхронных выражений, вы можете завершить выполнение всех своих выражений и оценить данные каждого из них по отдельности. Вернёмся к примеру с мини-игрой. Допустим, вы хотите выдать каждой команде усилители с учётом её результата. Вот тут-то и пригодится выражение 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)Если нужно запустить асинхронное выражение для нескольких элементов массива, как раз пригодится функция ArraySync(), которая позволит гарантировать их синхронизацию.
Каждое из этих выражений с конкурентным выполнением само по себе является мощным инструментом, и, научившись комбинировать и использовать их совместно, вы сможете писать код для обработки любой ситуации. Рассмотрим данный пример из «Шаблона гоночной трассы с сохранением данных с помощью Verse», в котором объединены несколько выражений с одновременным выполнением, чтобы можно было не только воспроизводить вступительную часть для каждого игрока перед началом гонки, но и отменить её, если игрок покинет игру во время её воспроизведения. В этом примере показано, насколько по-разному можно использовать одновременное выполнение и создавать универсальный код, который динамически будет реагировать на различные события.
# 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: