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 표현식으로 속도가 느린 주요 구역을 찾아 문제를 해결하세요.
로거 및 로그 출력
Verse 코드에서 Print()를 호출해 출력하는 메시지는 디폴트로 Print 로그에 작성됩니다. 출력된 메시지는 게임 내 화면, 게임 내 로그, UEFN의 출력 로그(Output Log)에 표시됩니다.
Print() 함수를 사용해 출력하는 메시지는 출력 로그, 게임 내 로그 탭, 게임 내 화면에 작성됩니다.
하지만 게임 내 화면에 메시지가 출력되면 안 되는 상황도 많습니다. 이벤트 실행이나 시간 경과 등 게임에서 발생하는 일을 추적하고 싶은 경우나, 코드가 잘못되었을 때 신호를 받고 싶은 경우를 예로 들 수 있습니다. 게임플레이 중에 여러 개의 메시지, 특히 플레이어와 관련 없는 정보가 담긴 텍스트가 표시되면 몰입감에 방해가 될 수 있습니다.
로거를 사용하면 이 문제를 해결할 수 있습니다. 로거는 메시지를 화면에 표시하지 않고 출력 로그 및 로그(Log) 탭에 직접 출력할 수 있는 특수 클래스입니다.
로거
로거를 빌드하려면 로그 채널을 만들어야 합니다. 모든 로거는 출력 로그로 메시지를 출력해 줍니다. 따라서 어떤 메시지가 어떤 로거에서 전달되었는지 구별하기 어려울 수 있습니다. 로그 채널은 어떤 로거에서 메시지를 보냈는지 쉽게 알 수 있도록 메시지 시작 부분에 로그 채널의 이름을 표시해 줍니다. 로그 채널은 모듈 스코프에서 선언되고, 로거는 클래스 또는 함수 안에서 선언됩니다. 다음은 모듈 스코프에서 로그 채널을 선언한 다음 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() 함수를 사용해 출력하는 메시지는 출력 로그와 게임 내 로그 탭에 작성됩니다.
로그 수준
로그 채널뿐 아니라 로거가 메시지를 출력하는 디폴트 로그 수준을 지정할 수도 있습니다. 로그 수준은 5개로 나뉘며, 각 레벨은 각자의 프로퍼티를 가지고 있습니다.
| 로그 수준 | 출력 위치 | 특수 프로퍼티 |
|---|---|---|
디버그 | 게임 내 로그 | N/A |
상세(Verbose) | 게임 내 로그 | N/A |
노멀 | 게임 내 로그, 출력 로그 | N/A |
경고 | 게임 내 로그, 출력 로그 | 노란색 텍스트 색상 |
Error | 게임 내 로그, 출력 로그 | 빨간색 텍스트 색상 |
로거를 생성하면 기본으로 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입니다. 모든 로거는 Print()를 호출할 때 log_level을 지정하는 방식으로, 어떤 로그 수준으로도 메시지를 출력할 수 있습니다.
로거를 사용해 다양한 로그 수준에 메시지를 출력한 결과입니다. 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()를 호출하고, LevelTwo()는 다시 LevelThree()를 호출하고, 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 코드를 비동기 함수로 옮기고 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() 함수가 실행되는 동안 장치가 다른 코드를 실행할 수 있도록 개선되었습니다. 하지만 함수는 여전히 각 플레이어를 거쳐 계속 루프 형태로 실행되어야 하므로, 경험에 참여하는 플레이어가 늘어나면 타이밍 문제가 발생할 수 있습니다. 예를 들어 각 플레이어에게 HP를 기준으로 점수를 부여하거나 플레이어가 아이템을 들고 있는지 확인해야 한다면 어떨까요? 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.
loop 표현식 안에서 spawn 표현식을 사용하는 경우, 코드가 잘못 처리되면 원하지 않은 방식으로 작동할 수 있습니다. 예를 들어 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 스타터 템플릿은 여러 커스텀 이벤트를 사용해 캐릭터 움직임을 제어하고, UI를 업데이트하고, 전체적인 게임 루프를 보드 단위로 관리합니다.
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: