Когда что-то в коде Verse не работает должным образом, иногда сложно понять, что пошло не так. Вы можете наблюдать следующее:
- Ошибки среды выполнения.
- Код выполняется в неправильном порядке.
- Процессы занимают больше времени, чем требуется.
Любая из этих ошибок может привести к тому, что код будет работать не так, как надо, что нарушит игровой процесс. Процесс диагностирования проблем в коде называется отладкой, и существует несколько различных решений, которые можно использовать для исправления и оптимизации кода.
Ошибки среды выполнения Verse
Код Verse анализируется как во время его написания на языковом сервере, так и при его компиляции из редактора или Visual Studio Code. Однако сам по себе семантический анализ не может выявить все возможные проблемы. Когда код выполняется в среде выполнения, можно инициировать ошибки среды выполнения. Это приведёт к остановке выполнения всего последующего кода Verse, из-за чего в вашу игру станет невозможно играть.
Для примера представим, что у вас есть код Verse, который делает следующее:
# Имеет спецификатор suspends, поэтому может быть вызван в выражении loop.
SuspendsFunction()<suspends>:void={}
# Вызывает SuspendFunction бесконечно без прерывания или возврата значений,
# что приводит к ошибке среды выполнения из-за бесконечного цикла.
CausesInfiniteLoop()<suspends>:void=
loop:
SuspendsFunction()
Функция CausesInfiniteLoop()
не будет вызывать ошибок в компиляторе Verse, и ваша программа успешно скомпилируется. Однако если вызвать CausesInfiniteLoop()
в среде выполнения, то запустится бесконечный цикл и возникнет ошибка среды выполнения.
Чтобы изучить ошибки среды выполнения, возникшие в игре, см. Портал сервиса управления контентом. Здесь есть список всех ваших проектов, как опубликованных, так и неопубликованных. Для каждого проекта вы сможете открыть вкладку Verse, где будет список категорий ошибок среды выполнения, возникавших в проекте. Вы также можете проверить стек вызовов Verse, в котором было сообщено о конкретной ошибке, — так вы получите больше информации о том, что могло пойти не так. Отчёты об ошибках хранятся до 30 дней.

Учтите, что эта функция находится на раннем этапе разработки и принцип её работы может быть изменён в последующих версиях UEFN и Verse.
Профилирование медленного кода
Если код работает медленнее, чем нужно, можно протестировать его с помощью выражения profile. Выражение profile сообщает, сколько времени требуется на выполнение определённого фрагмента кода, и может помочь вам выявить медленные блоки кода и оптимизировать их. Представим, что вы хотите узнать, содержит ли массив определённое число, и вернуть индекс, в котором это число содержится. Это можно сделать, перебрав элементы массива и проверив, совпадает ли число с искомым.
# Массив тестовых чисел.
TestNumbers:[]int = array{1,2,3,4,5}
# Выполняется при запуске устройства в работающей игре
OnBegin<override>()<suspends>:void=
# Определим путём итерации, существует ли такое число в массиве TestNumbers
# перебираем каждый элемент и проверяем, совпадают ли они.
for:
Index -> Number:TestNumbers
Number = 4
do:
Print("Найдено число с индексом {Index}!")
Однако этот код неэффективен, поскольку вам придётся проверять каждое число в массиве на совпадение. Это приводит к неэффективной временной сложности, поскольку, даже если элемент будет найден, программа продолжит проверку остальных элементов из списка. Вместо этого вы можете использовать функцию Find[]
, чтобы проверить, содержит ли массив искомое число, и вернуть его. Поскольку Find[]
возвращает значение сразу же при нахождении элемента, то чем раньше этот элемент будет в списке, тем быстрее будет выполняться функция. Если вы используете выражение profile
для тестирования обеих частей кода, вы увидите, что в данном случае коду, использующему функцию Find[]
, требуется меньше времени на выполнение.
# Выполняется при запуске устройства в работающей игре
OnBegin<override>()<suspends>:void=
# Определим путём итерации, существует ли такое число в массиве TestNumbers
# перебираем каждый элемент и проверяем, совпадают ли они.
profile("Поиск числа путём проверки каждого элемента массива"):
for:
Index -> Number:TestNumbers
Number = 4
do:
Print("Найдено число с индексом {Index}!")
# Определяем, существует ли число, используя функцию Find[].
profile("Поиск числа с помощью функции Find[]"):
if:
FoundIndex := TestNumbers.Find[4]
then:
Print("Найдено число с индексом {FoundIndex}!")
else:
Print("Невозможно найти это число!")
Эти небольшие различия во времени выполнения усиливаются по мере увеличения количества элементов для итерации. Каждое выражение, которое вы выполняете при итерации по большому списку, добавляет временную сложность, особенно когда ваши массивы разрастаются до сотен или даже тысяч элементов. По мере масштабирования игры, чтобы охватывать всё больше игроков, используйте выражение profile
для поиска и обработки ключевых областей замедления.
Регистраторы и записи в журнал выходных данных
По умолчанию при вызове Print()
в коде Verse сообщение для вывода записывается в специальный журнал Print
. Сообщения отображаются на экране в игре, во внутриигровом журнале и в Журнале выходных данных в UEFN.
Когда вы выводите сообщение с помощью функции Print(), оно записывается в Журнал выходных данных, на внутриигровой вкладке Журнал и на экран в игре.
Однако во многих случаях выводить сообщения на экран в игре нет необходимости. Сообщения можно использовать для отслеживания различных моментов, например возникновения события или истечения определённого времени, а также для вывода какой-либо ошибки в коде при его выполнении. Большое количество сообщений во время игры может отвлекать, особенно если они не имеют существенного значения для игрока.
Для решения этого вопроса можно использовать регистратор. Регистратор — это специальный класс, который позволяет выводить сообщения непосредственно в Журнал выходных данных и на вкладку Журнал без вывода их на экран.
Регистраторы
Чтобы создать регистратор, сначала нужно создать канал записи в журнал. Каждый регистратор выводит сообщения в журнал выходных данных, но может быть сложно определить, какой именно регистратор вывел то или иное сообщение. Каналы записи в журнал добавляют свои названия в начало сообщения, что позволяет легко определить, какой регистратор отправил сообщение. Каналы записи в журнал объявляются в области видимости модуля, а регистраторы объявляются внутри классов или функций. Ниже приведён пример объявления канала записи в журнал в области видимости модуля, а затем объявления и вызова регистратора внутри устройства Verse.
using { /Fortnite.com/Devices }
using { /Verse.org/Simulation }
using { /UnrealEngine.com/Temporary/Diagnostics }
# Канал записи в журнал для класса debugging_tester.
# Каналы записи в журнал, объявленные в области видимости модуля, могут использоваться любым классом.
debugging_tester_log := class(log_channel){}
# Это Verse-устройство творческого режима, которое можно разместить на уровне
debugging_tester := class(creative_device):
# Диспетчер журналов, локальный для класса debugging_tester.
Logger:log = log{Channel := debugging_tester_log}
# Выполняется при запуске устройства в работающей игре
OnBegin<override>()<suspends>:void=
Print("Это говорит канал вывода сообщений!")
Logger.Print("А это говорит тестировщик отладки!")
Когда вы выводите сообщение с помощью функции Print() регистратора, оно заносится в Журнал выходных данных и на внутриигровую вкладку Журнал.
Уровни журналов
Помимо каналов вы также можете указать уровень журнала по умолчанию, в который регистратор будет отправлять сообщения. Существует пять уровней журналов, каждый со своими свойствами:
Уровень журнала | Печатает в | Особые свойства |
---|---|---|
Debug (Отладка) | Внутриигровой журнал | N/A |
Verse | Внутриигровой журнал | N/A |
Normal (Обычное событие) | Внутриигровой журнал, Журнал выходных данных | N/A |
Warning (Предупреждение) | Внутриигровой журнал, Журнал выходных данных | Цвет текста — жёлтый |
Error (Ошибка) | Внутриигровой журнал, Журнал выходных данных | Цвет текста — красный |
При создании регистратора по умолчанию задаётся уровень журнала Normal
. Вы можете изменить уровень журнала при создании регистратора или указать его при вызове Print()
.
# Диспетчер журналов, локальный для класса debugging_tester. По умолчанию выводится
# до log_level.Normal.
Logger:log = log{Channel := debugging_tester_log}
# Регистратор с log_level.Debug в качестве канала записи в журнал по умолчанию.
DebugLogger:log = log{Channel := debugging_tester_log, DefaultLevel := log_level.Debug}
# Выполняется при запуске устройства в работающей игре
OnBegin<override>()<suspends>:void=
# Регистратор по умолчанию выводит данные в канал записи в журнал уровня Normal, а DebugLogger —
# в канал журнала уровня Debug. Любой регистратор может отправлять сообщения в журнал любого уровня путём
# указания аргумента ?Level при вызове Print()
Logger.Print("Это сообщение выводится в канале записи в журнал уровня Normal!")
DebugLogger.Print("А это сообщение выводится в канал записи в журнал уровня Debug!")
Logger.Print("Это сообщение также можно вывести в канал записи в журнал уровня Debug!", ?Level := log_level.Debug)
В примере выше Logger
по умолчанию использует канал записи в журнал Normal
, а DebugLogger
— Debug
. Любой регистратор может отправлять сообщения в журнал любого уровня путём указания log_level
при вызове Print()
.
Результаты использования регистратора для вывода сообщений в журнал разных уровней. Обратите внимание, что log_level.Debug и log_level.Verbose выводят сообщения не во внутриигровой журнал, а только в Журнал выходных данных UEFN.
Вывод стека вызовов
Стек вызовов отслеживает список вызовов функций, которые привели в текущую область видимости. Он похож на набор инструкций, которые используются кодом для того, чтобы знать, куда вернуться после завершения выполнения текущей подпрограммы. Стек вызовов можно вывести из любого регистратора с помощью функции PrintCallStack()
. Для примера возьмём следующий код:
# Диспетчер журналов, локальный для класса debugging_tester. По умолчанию выводится
# в log_level.Normal.
Logger:log = log{Channel := debugging_tester_log}
# Выполняется при запуске устройства в работающей игре
OnBegin<override>()<suspends>:void=
# Перейдём в первую функцию и выведем стек вызовов через несколько уровней.
LevelOne()
# Вызывает LevelTwo(), чтобы перейти на один уровень ниже.
LevelOne():void=
LevelTwo()
# Вызывает LevelThree(), чтобы перейти на один уровень ниже.
LevelTwo():void=
LevelThree()
# Выводим стек вызовов, который выведет последовательность
# вызовов функций, которые привели в это место.
LevelThree():void=
Logger.PrintCallStack()
Код в OnBegin()
выше вызывает LevelOne()
для перехода к первой функции. Затем LevelOne()
вызывает LevelTwo()
, которая вызывает функцию LevelThree()
, которая вызывает Logger.PrintCallStack()
для вывода текущего стека вызовов. Самый последний вызов будет на вершине стека, поэтому LevelThree()
будет выводиться в первую очередь. Затем LevelTwo()
, LevelOne()
и OnBegin()
.
Если код будет выполняться неверно, вывод стека вызовов очень пригодится при определении вызовов, которые привели к ошибке. Это позволит вам увидеть структуру кода во время его выполнения и изолировать отдельные трассировки стека в проектах со сложным кодом.
Визуализация игровых данных с помощью отладочного рисования
Ещё один способ отладки различных функций вашей игры — это использование API отладочного рисования. Этот API может создавать отладочные формы для визуализации игровых данных. Вот некоторые примеры:
- Линия обзора охранника.
- Расстояние, на которое устройство перемещения объектов будет перемещать объект.
- Расстояние затухания аудиопроигрывателя.
С помощью этих отладочных форм вы можете точно настроить свою игру, не предоставляя эти данные в опубликованную игру. Подробнее: Отладочное рисование в Verse.
Оптимизация и синхронизация времени с одновременным выполнением
Одновременное выполнение лежит в основе языка программирования Verse и является мощным инструментом, позволяющим улучшить игровой процесс. Благодаря этому одно устройство Verse может выполнять несколько операций одновременно. Это позволяет писать более гибкий и компактный код и ограничивать количество устройств на уровне. Одновременное выполнение — это отличный инструмент оптимизации, а поиск способов использования асинхронного кода для одновременного выполнения нескольких задач позволит вам ускорить выполнение программ и устранить проблемы, связанные с распределением времени.
Создание асинхронных контекстов с помощью spawn
Выражение spawn
запускает асинхронное выражение из любого контекста, обеспечивая при этом немедленное выполнение последующих выражений. Оно позволяет запускать несколько задач одновременно с одного устройства без необходимости создавать новые файлы Verse для каждой из них. Для примера рассмотрим сценарий с кодом, который каждую секунду отслеживает здоровье каждого игрока. Если здоровье игрока падает ниже определённого значения, нужно немного исцелить его. После этого нужно запустить некоторый код, обрабатывающий другую задачу. Устройство, в котором реализован этот код, может выглядеть примерно так:
# Это Verse-устройство творческого режима, которое можно разместить на уровне
healing_device := class(creative_device):
# Выполняется при запуске устройства в работающей игре
OnBegin<override>()<suspends>:void=
AllPlayers:[]agent = GetPlayspace().GetPlayers()
# Каждую секунду проверяем каждого игрока. Если у игрока осталось меньше половины здоровья,
# исцелить его.
loop:
for:
Player:Players
Character := Player.GetFortCharacter[]
PlayerHP := Character.GetHealth()
PlayerHP <= PrintThreshold
do:
Character.SetHealth(PlayerHP + SmallHeal)
Sleep(1.0)
Print("Это остальная часть кода!")
Однако, поскольку этот цикл выполняется бесконечно и не прерывается, любой код, следующий за ним, не будет выполнен. Этот дизайн является ограничивающим, поскольку устройство зависает, выполняя только выражение loop. Чтобы устройство могло обрабатывать несколько задач одновременно и выполнять код конкурентно, можно поместить код loop
в асинхронную функцию и активировать его во время OnBegin()
.
# Выполняется при запуске устройства в работающей игре
OnBegin<override>()<suspends>:void=
# Используйте выражение spawn для асинхронного запуска HealMonitor().
spawn{HealMonitor()}
# Код, следующий за ним, будет выполнен сразу.
Print("Этот код будет выполняться, пока будет выполняться созданное выражение")
HealMonitor(Players:[]agent)<suspends>:void=
# Каждую секунду проверяем каждого игрока. Если у игрока осталось меньше половины здоровья,
# исцелить его.
loop:
for:
Player:Players
Character := Player.GetFortCharacter[]
PlayerHP := Character.GetHealth()
PlayerHP <= PrintThreshold
do:
Character.SetHealth(PlayerHP + SmallHeal)
Sleep(1.0)
Таким образом, код был улучшен, поскольку теперь устройство может выполнять другой код, пока выполняется функция HealMonitor()
. Однако функция всё равно должна перебирать в цикле каждого игрока, и чем больше игроков в игре, тем вероятнее всего возникнут проблемы с синхронизацией времени. К примеру, что если бы нужно было начислять каждому игроку очки с учётом его здоровья или проверять, удерживает ли он предмет? Добавление дополнительной логики для каждого игрока в выражение for
увеличивает временную сложность для этой функции, и при достаточном количестве игроков один игрок может не получить лечение своевременно в случае получения урона из-за проблем с синхронизацией времени.
Вместо того чтобы перебирать в цикле каждого игрока и проверять их по отдельности, можно ещё больше оптимизировать этот код, создав экземпляр функции для каждого игрока. Это означает, что одна функция может отслеживать одного игрока, благодаря чему коду не нужно проверять каждого игрока, прежде чем вернуться к тому, кому требуется лечение. Использование выражений с одновременным выполнением, таких как spawn
, может сделать код более эффективным и гибким, а также высвободить часть кода для выполнения других задач.
# Выполняется при запуске устройства в работающей игре
OnBegin<override>()<suspends>:void=
AllPlayers := GetPlayspace().GetPlayers()
# Создаём экземпляр функции HealMonitor() для каждого игрока.
for:
Player:AllPlayers
do:
# Используем выражение spawn для асинхронного запуска HealMonitorPerPlayer().
spawn{HealMonitorPerPlayer(Player)}
# Код, следующий за ним, будет выполнен сразу.
Print("Этот код будет выполняться, пока будет выполняться созданное выражение")
HealMonitorPerPlayer(Player:agent)<suspends>:void=
if:
Character := Player.GetFortCharacter[]
then:
# Каждую секунду проверяем отслеживаемого игрока. Если у игрока осталось меньше половины здоровья,
# исцелить его.
loop:
PlayerHP := Character.GetHealth()
if:
PlayerHP <= PrintThreshold
then:
Character.SetHealth(PlayerHP + SmallHeal)
Sleep(1.0)
Неправильное использование выражения spawn
внутри выражения loop
может привести к нежелательному поведению. Например, поскольку HealMonitorPerPlayer()
никогда не завершается, этот код будет порождать бесконечное количество асинхронных функций до тех пор, пока не возникнет ошибка среды выполнения.
# Создаём экземпляр функции HealMonitor() для каждого игрока, формируя бесконечный цикл.
# Это приведёт к ошибке среды выполнения, поскольку количество асинхронных функций бесконечно увеличивается.
loop:
for:
Player:AllPlayers
do:
spawn{HealMonitorPerPlayer(Player)}
Sleep(0.0)
Управление синхронизацией времени с помощью событий
Корректная синхронизация каждой части кода может оказаться затруднительной, особенно в масштабных многопользовательских играх с большим количеством сценариев, выполняемых одновременно. Разные части вашего кода могут зависеть от других функций или сценариев, выполняемых в определённом порядке, и это может создать проблемы во времени между ними при отсутствии строгого контроля. Для примера рассмотрим следующую функцию, которая ведёт обратный отсчёт в течение определённого времени, а затем начисляет переданному в неё игроку некоторые очки, если его здоровье больше порогового значения.
CountdownScore(Player:agent)<suspends>:void=
# Ожидаем некоторое время, затем начисляем очки каждому игроку, чьё здоровье выше порогового значения.
Sleep(CountdownTime)
if:
Character := Player.GetFortCharacter[]
PlayerHP := Character.GetHealth()
PlayerHP >= PrintThreshold
then:
ScoreManager.Activate(Player)
Поскольку эта функция имеет модификатор <suspends>
, можно запускать её экземпляр асинхронно для каждого игрока с помощью spawn()
. Однако нужно сделать так, чтобы любой другой код, зависящий от этой функции, всегда запускался после её завершения. Что если нужно вывести каждого игрока, который набрал очки после завершения CountdownScore()
? Это можно сделать во время OnBegin()
, вызвав Sleep()
, чтобы подождать столько же времени, сколько требуется для выполнения CountdownScore()
. Однако это может создать проблемы с синхронизацией времени, когда игра активна, а также вводит новую переменную, которую вам нужно постоянно обновлять, если вы вдруг захотите внести изменения в свой код. Вместо этого вы можете создавать пользовательские события и вызывать для них Await()
, чтобы строго контролировать порядок событий в коде.
# Пользовательское событие, сигнализирующее об окончании обратного отсчёта.
CountdownCompleteEvent:event() = event(){}
# Выполняется при запуске устройства в работающей игре
OnBegin<override>()<suspends>:void=
AllPlayers := GetPlayspace().GetPlayers()
# Создаём экземпляр функции CountdownScore для каждого игрока
for:
Player:AllPlayers
do:
spawn{CountdownScore(Player)}
# Ожидаем получения сигнала о CountdownCompletedEvent.
CountdownCompleteEvent.Await()
# Если игрок набрал очки, выводим их в журнал.
for:
Player:AllPlayers
CurrentScore := ScoreManager.GetCurrentScore(Player)
CurrentScore > 0
do:
Print("У этого игрока есть очки!")
CountdownScore(Player:agent)<suspends>:void=
# Ожидаем некоторое время, затем начисляем очки каждому игроку, чьё здоровье выше порогового значения.
Sleep(CountdownTime)
if:
Character := Player.GetFortCharacter[]
PlayerHP := Character.GetHealth()
PlayerHP >= HPThreshold
then:
ScoreManager.Activate(Player)
# Передаём сигнал о событии, чтобы можно было выполнить любой код, ожидающий его.
CountdownCompleteEvent.Signal()
Поскольку теперь этот код ожидает получения сигнала о CountdownCompletedEvent()
, он проверит счёт каждого игрока только после завершения выполнения CountdownScore()
. Многие устройства имеют встроенные события, для которых можно вызывать Await()
, чтобы управлять временем выполнения кода. Используя эти события, вы можете создавать сложные игровые циклы с несколькими частями, положение которых можно изменять. К примеру, Стартовый шаблон Verse использует несколько пользовательских событий для контроля перемещения персонажей, обновления интерфейса и управления общим игровым циклом от начала и до конца.
Обработка нескольких выражений с помощью Sync, Race и Rush
Sync, race и rush позволяют запускать несколько асинхронных выражений одновременно, выполняя различные функции, когда эти выражения завершают выполнение. Используя каждое из них, вы можете чётко контролировать время существования каждого из ваших асинхронных выражений, что позволит создать более динамичный код, способный обрабатывать разные ситуации.
Для примера возьмём выражение rush
. Оно запускает одновременно несколько асинхронных выражений, но возвращает только значение выражения, которое завершится первым. Предположим, у вас есть мини-игра, в которой команды должны выполнить какое-либо задание, при этом команда, выполнившая его первой, получает усилитель, который позволяет ей мешать другим игрокам, пока они выполняют задание. Вы можете написать сложную временную логику для отслеживания выполнения задания каждой командой или использовать выражение rush
. Поскольку выражение возвращает значение первого завершённого асинхронного выражения, оно вернёт команду-победителя. При этом код, обрабатывающий другие команды, сможет продолжить выполнение.
WinningTeam := rush:
# Все три асинхронные функции начинают выполняться одновременно.
RushToFinish(TeamOne)
RushToFinish(TeamTwo)
RushToFinish(TeamThree)
# Следующее выражение вызывается сразу по завершении любой из асинхронных функций.
GrantPowerup(WinnerTeam)
Выражение race
следует тем же правилам, за исключением того, что при завершении асинхронного выражения другие выражения отменяются. Оно позволяет строго контролировать время существования нескольких асинхронных выражений одномоментно, и вы даже можете совместить его с выражением sleep()
, чтобы ограничить время выполнения выражения. Рассмотрим пример с rush
, но на этот раз нужно завершить мини-игру немедленно в случае победы одной из команд. Также нужно добавить таймер, чтобы мини-игра не длилась бесконечно. Выражение race
позволяет выполнять и то, и другое, без необходимости использовать события или другие инструменты одновременного выполнения, чтобы знать, когда нужно отменить оставшиеся выражения.
WinningTeam := race:
# Все четыре асинхронные функции начинают выполняться одновременно.
RaceToFinish(TeamOne)
RaceToFinish(TeamTwo)
RaceToFinish(TeamThree)
Sleep(TimeLimit)
# Следующее выражение вызывается сразу по завершении любой из асинхронных функций. Любые другие асинхронные функции отменяются.
GrantPowerup(WinnerTeam)
Наконец, выражение sync
позволяет дождаться завершения выполнения нескольких выражений, гарантируя тем самым, что каждое из них завершится, прежде чем код продолжит выполняться. Поскольку выражение sync
возвращает кортеж, содержащий результаты каждого из асинхронных выражений, вы можете завершить выполнение всех своих выражений и оценить данные каждого из них по отдельности. Вернёмся к примеру с мини-игрой. Допустим, вы хотите выдать каждой команде усилители с учётом её результата. Вот тут-то и пригодится выражение sync
.
TeamResults := sync:
# Все три асинхронные функции начинают выполняться одновременно.
WaitForFinish(TeamOne)
WaitForFinish(TeamTwo)
WaitForFinish(TeamThree)
# Следующее выражение будет вызвано только тогда, когда завершатся все асинхронные выражения.
GrantPowerups(TeamResults)
Если вы хотите запустить асинхронное выражение для нескольких элементов массива, используйте удобный метод ArraySync()
, чтобы гарантировать их синхронизацию.
Каждое из этих выражений с конкурентным выполнением само по себе является мощным инструментом, и, научившись комбинировать и использовать их совместно, вы сможете писать код для обработки любой ситуации. Рассмотрим данный пример из Шаблона гоночной трассы с сохранением данных с помощью Verse, в котором объединены несколько выражений с одновременным выполнением, чтобы можно было не только воспроизводить вступительную часть для каждого игрока перед началом гонки, но и отменить её, если игрок покинет игру во время её воспроизведения. В этом примере показано, насколько по-разному можно использовать одновременное выполнение и создавать универсальный код, который динамически будет реагировать на различные события.
# Дожидаемся начала представления игрока и отображаем его информацию.
# Отменяем ожидание, если он покидает игру.
WaitForPlayerIntro(Player:agent, StartOrder:int)<suspends>:void=
var IntroCounter:int = 0
race:
# Ожидаем завершения гонки этим игроком и затем регистрируем завершение.
loop:
sync:
block:
StartPlayerIntroEvent.TriggeredEvent.Await()
if (IntroCounter = StartOrder):
PlayerLeaderboard.UpdatePopupUI(Player, PopupDialog)
EndPlayerIntroEvent.TriggeredEvent.Await()
if (IntroCounter = StartOrder):
break
set IntroCounter += 1
# Ожидаем выхода этого игрока из игры.
loop:
LeavingPlayer := GetPlayspace().PlayerRemovedEvent().Await()
if:
LeavingPlayer = Player
then:
break