Verse 코드가 생각처럼 작동하지 않거나, 어디에서 오류가 발생했는지 찾기 어려울 때가 있습니다. 예를 들어 다음과 같은 경우가 있을 수 있습니다.
- 런타임 오류
- 코드 실행 순서가 잘못된 경우
- 프로세스가 생각보다 오래 걸리는 경우
이러한 경우 코드가 예상치 못한 방식으로 작동하여 제작한 경험에서 문제가 생길 수 있습니다. 코드의 문제를 진단하는 것을 디버깅이라고 하는데, 코드를 고치고 최적화하는 데 사용할 수 있는 몇 가지 솔루션이 있습니다.
Verse 런타임 오류
Verse 코드는 언어 서버에서 코드를 작성할 때, 그리고 에디터 또는 Visual Studio Code에서 컴파일할 때 모두 분석을 거칩니다. 하지만 이러한 시맨틱 분석만으로는 모든 문제를 다 찾을 수 없으며, 코드가 런타임에서 실행될 때 런타임 오류 가 발생할 수 있습니다. 오류가 발생하면 Verse 코드 실행이 중단되므로 제작한 경험을 계속 플레이할 수 없게 됩니다.
예를 들어 아래와 같은 Verse 코드를 작성했다고 가정해 보겠습니다.
# suspends 지정자가 있어서 루프 표현식에서 호출이 가능합니다.
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=
# 각 엘리먼트에 반복작업을 적용하여 일치하는지 확인하는 방식으로
# 배열에 원하는 숫자가 있는지 찾습니다.
for:
Index -> Number:TestNumbers
Number = 4
do:
Print("Found the number at Index {Index}!")
하지만 이 코드는 배열에 있는 모든 숫자의 일치 여부를 확인해야 하므로 비효율적입니다. 원하는 엘리먼트를 찾아도 나머지 목록을 계속 확인하기 때문에 비효율적인 시간 복잡도 문제가 발생합니다. 대신 Find[]
함수를 사용해 배열에 원하는 숫자가 있는지 확인하고 반환하는 방법을 사용할 수 있습니다. Find[]
함수는 엘리먼트를 찾는 즉시 반환하므로 해당 엘리먼트가 목록 초반에 있을수록 실행 속도도 빨라집니다. 두 코드 모두에서 profile
표현식을 사용해 보면, 이 경우 Find[]
함수를 사용했을 때 실행 시간이 짧다는 것을 알 수 있습니다.
# 실행 중인 게임에서 장치가 시작되면 실행됩니다.
OnBegin<override>()<suspends>:void=
# 각 엘리먼트에 반복작업을 적용하여 일치하는지 확인하는 방식으로
# 배열에 원하는 숫자가 있는지 찾습니다.
profile("Finding a number by checking each array element"):
for:
Index -> Number:TestNumbers
Number = 4
do:
Print("Found the number at Index {Index}!")
# Find[] 함수로 원하는 숫자가 있는지 찾습니다.
profile("Finding a number using the Find[] function"):
if:
FoundIndex := TestNumbers.Find[4]
then:
Print("Found the number at Index {FoundIndex}!")
else:
Print("Failed to find the number!")
이 경우에는 실행 시간의 차이가 크지 않지만, 반복작업을 해야 하는 요소가 늘어날수록 차이가 벌어집니다. 방대한 목록에 반복작업을 적용하면 실행되는 각 표현식마다 시간 복잡도가 증가합니다. 특히, 배열에 수백 개, 혹은 수천 개의 엘리먼트가 있는 경우에는 더욱 그렇습니다. 스케일을 키워서 경험을 더 많은 플레이어에게 제공하게 되면, profile
표현식으로 속도가 느린 주요 구역을 찾아 문제를 해결하세요.
로거 및 로그 출력
Verse 코드에서 Print()
를 호출해 출력하는 메시지는 디폴트로 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("This is the print channel speaking!")
Logger.Print("And this is the debugging tester speaking!")
로거의 Print() 함수를 사용해 출력하는 메시지는 출력 로그 와 게임 내 로그 탭에 작성됩니다.
로그 수준
로그 채널뿐 아니라 로거가 메시지를 출력하는 디폴트 로그 수준 을 지정할 수도 있습니다. 로그 수준은 5개로 나뉘며, 각 레벨은 각자의 프로퍼티를 가지고 있습니다.
로그 수준 | 출력 위치 | 특수 프로퍼티 |
---|---|---|
디버그(Debug) | 게임 내 로그 | 해당 없음 |
상세(Verbose) | 게임 내 로그 | 해당 없음 |
일반(Normal) | 게임 내 로그, 출력 로그 | 해당 없음 |
경고(Warning) | 게임 내 로그, 출력 로그 | 노란색 텍스트 컬러 |
오류(Error) | 게임 내 로그, 출력 로그 | 빨간색 텍스트 컬러 |
로거를 생성하면 기본으로 일반
로그 수준이 지정됩니다. 로거의 수준은 로거를 생성할 때 바꿀 수도 있고, 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=
# Logger는 기본으로 일반 로그 채널에 메시지를 출력하지만,
# DebugLogger는 디버그 로그 채널에 출력합니다. Print()를 호출할 때 ?Level 실행인자를 지정하면
# 모든 로거가 어떤 레벨에든 메시지를 출력할 수 있습니다.
Logger.Print("This message prints to the Normal log channel!")
DebugLogger.Print("And this message prints to the Debug log channel!")
Logger.Print("This can also print to the Debug channel!", ?Level := log_level.Debug)
위 예시를 보면 Logger
의 기본 로그 채널은 Normal
이지만, DebugLogger
의 기본 로그 채널은 Debug
입니다. 모든 로거는 Print()
를 호출할 때 log_level
을 지정하면 어떤 로그 수준에도 메시지를 출력할 수 있습니다.
로거를 사용해 다양한 로그 수준에 메시지를 출력한 결과입니다. 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()
를 호출하고, LevelTwo()
는 다시 LevelThree()
를 호출하고, LevelThree()
는 Logger.PrintCallStack()
을 호출해 현재 콜 스택을 출력합니다. 가장 최근 호출이 스택 맨 위에 배치되기 때문에 LevelThree()
가 가장 먼저 출력됩니다. 그 다음으로 LevelTwo()
, LevelOne()
, OnBegin()
이 순서대로 출력됩니다.
코드에서 문제가 발생했을 때 콜 스택을 출력하면, 문제 지점에 해당하는 호출이 무엇인지 정확히 알 수 있어 유용합니다. 이렇게 하면 코드가 실행되는 동안 코드 구조를 쉽게 볼 수 있고, 방대한 코드를 가진 프로젝트에서 개별 스택 트레이스를 따로 분리할 수 있습니다.
디버그 드로를 사용해 게임 데이터 시각화하기
여러분이 제작한 경험의 다양한 기능을 디버깅하는 또 다른 방법은 디버그 드로(Debug Draw) API를 사용하는 것입니다. 디버그 드로 API는 디버그 셰이프를 빌드해 게임 데이터를 시각화해 줍니다. 몇 가지 예를 들면 다음과 같습니다.
- 경비의 시야
- 사물 이동 장치가 오브젝트를 움직일 거리
- 오디오 플레이어의 감쇠 거리
이러한 디버그 셰이프를 사용하면 퍼블리싱된 경험에 데이터를 노출하지 않고도 경험을 세부 조정할 수 있습니다. 자세한 내용은 Verse의 디버그 드로를 참고하세요.
동시성을 활용한 최적화와 타이밍
동시성은 Verse 프로그래밍 언어의 핵심이자 경험을 강화해 줄 수 있는 강력한 도구입니다. 동시성을 활용하면 하나의 Verse 장치로 한 번에 여러 개의 연산을 실행할 수 있습니다. 따라서 더 유연하면서 간결한 코드를 작성할 수 있으며, 레벨에 사용되는 장치의 수를 줄일 수 있습니다. 동시성은 뛰어난 최적화 도구로, 비동기 코드를 사용해 여러 작업을 한 번에 처리하는 방법을 찾아 프로그램의 실행 속도를 높일 수 있고 타이밍 관련 문제를 해결할 수 있습니다.
spawn으로 비동기 컨텍스트 생성하기
spawn
표현식은 어느 컨텍스트에서든 비동기 표현식을 시작하고 그 다음 표현식이 즉시 실행되도록 합니다. 따라서 각각 새로운 Verse 파일을 생성할 필요 없이 하나의 장치로 여러 작업을 동시에 실행할 수 있습니다. 예를 들어 각 플레이어의 체력을 초마다 모니터링하는 코드가 있다고 가정해 보겠습니다. 플레이어의 체력이 특정 수치 밑으로 떨어지면 플레이어를 약간 치유해 준 다음, 다른 작업을 수행하는 다른 코드를 실행하려 합니다. 이 코드를 구현하는 장치를 다음과 같이 구성할 수 있습니다.
# Verse로 작성하여 레벨에 배치할 수 있는 포크리 장치
healing_device := class(creative_device):
# 실행 중인 게임에서 장치가 시작되면 실행됩니다.
OnBegin<override>()<suspends>:void=
AllPlayers:[]agent = GetPlayspace().GetPlayers()
# 초마다 각 플레이어를 확인합니다. 플레이어의 체력이 50% 미만이 되면
# 플레이어를 약간 치유해 줍니다.
loop:
for:
Player:Players
Character := Player.GetFortCharacter[]
PlayerHP := Character.GetHealth()
PlayerHP <= HPThreshold
do:
Character.SetHealth(PlayerHP + SmallHeal)
Sleep(1.0)
Print("This is the rest of the code!")
하지만 이 루프는 중단되지 않고 영구 실행되므로 이후 코드가 실행되지 않게 됩니다. 장치가 루프 표현식만 반복해서 실행하는 한계가 있는 디자인인 것입니다. 장치가 한 번에 여러 작업을 수행하고 동시에 코드를 실행할 수 있도록 하려면 loop
코드를 비동기 함수로 옮기고 OnBegin()
에서 생성하면 됩니다.
# 실행 중인 게임에서 장치가 시작되면 실행됩니다.
OnBegin<override>()<suspends>:void=
# spawn 표현식을 사용해 HealMonitor()를 비동기로 실행합니다.
spawn{HealMonitor()}
# 이후 코드가 즉시 실행됩니다.
Print("This code keeps going while the spawned expression executes")
HealMonitor(Players:[]agent)<suspends>:void=
# 초마다 각 플레이어를 확인합니다. 플레이어의 체력이 50% 미만이 되면
# 플레이어를 약간 치유해 줍니다.
loop:
for:
Player:Players
Character := Player.GetFortCharacter[]
PlayerHP := Character.GetHealth()
PlayerHP <= HPThreshold
do:
Character.SetHealth(PlayerHP + SmallHeal)
Sleep(1.0)
이제 HealMonitor()
함수가 실행되는 동안 장치가 다른 코드를 실행할 수 있도록 개선되었습니다. 하지만 함수는 여전히 각 플레이어를 거쳐 계속 루프 형태로 실행되어야 하므로, 경험에 참여하는 플레이어가 늘어나면 타이밍 문제가 발생할 수 있습니다. 예를 들어 각 플레이어에게 HP를 기준으로 점수를 부여하거나 플레이어가 아이템을 들고 있는지 확인해야 한다면 어떨까요? for
표현식에 플레이어별 로직을 또 추가하면 함수의 시간 복잡도가 높아집니다. 여기에서 플레이어 수가 많아지면 타이밍 문제 때문에 피해를 입고 나서 치유를 받지 못하는 플레이어가 생길 수 있습니다.
이럴 때에는 각 플레이어를 루프 방식으로 확인하는 대신에 플레이어별로 함수의 인스턴스를 생성하여 코드를 최적화하면 됩니다. 즉, 하나의 함수가 하나의 플레이어를 모니터링하는 것입니다. 이렇게 하면 코드가 각 플레이어를 하나씩 확인한 후 치유가 필요한 플레이어에게 다시 돌아갈 필요가 없습니다. spawn
과 같은 동시성 표현식을 잘 활용하면 보다 효율적이고 유연한 코드를 만들 수 있을 뿐 아니라, 코드 베이스의 나머지 부분을 줄여 다른 작업을 처리할 수 있게 됩니다.
# 실행 중인 게임에서 장치가 시작되면 실행됩니다.
OnBegin<override>()<suspends>:void=
AllPlayers := GetPlayspace().GetPlayers()
# 각 플레이어에 대해 HealMonitor() 함수의 인스턴스를 생성합니다.
for:
Player:AllPlayers
do:
# spawn 표현식을 사용해 HealMonitorPerPlayer()를 비동기로 실행합니다.
spawn{HealMonitorPerPlayer(Player)}
# 이후 코드가 즉시 실행됩니다.
Print("This code keeps going while the spawned expression executes")
HealMonitorPerPlayer(Player:agent)<suspends>:void=
if:
Character := Player.GetFortCharacter[]
then:
# 초마다 해당하는 플레이어를 확인합니다. 플레이어의 체력이 50% 미만이 되면
# 플레이어를 약간 치유해 줍니다.
loop:
PlayerHP := Character.GetHealth()
if:
PlayerHP <= HPThreshold
then:
Character.SetHealth(PlayerHP + SmallHeal)
Sleep(1.0)
loop
표현식 안에서 spawn
표현식을 사용하는 경우, 코드가 잘못 처리되면 원하지 않은 방식으로 작동할 수 있습니다. 예를 들어 HealMonitorPerPlayer()
는 종료되지 않으므로, 이 코드는 런타임 오류가 발생할 때까지 무한히 비동기 함수를 생성하게 됩니다.
# 각 플레이어에 대해 HealMonitor() 함수의 인스턴스를 생성하는 영구 루프가 만들어집니다.
# 이렇게 되면 비동기 함수의 수가 무한히 증가하므로 런타임 오류가 발생합니다.
loop:
for:
Player:AllPlayers
do:
spawn{HealMonitorPerPlayer(Player)}
Sleep(0.0)
이벤트를 사용해 타이밍 제어하기
코드의 모든 부분을 정확하게 동기화하는 것은 어려울 수 있으며, 많은 스크립트가 동시에 실행되는 대규모 멀티플레이어 경험이라면 더욱 그렇습니다. 코드의 서로 다른 부분이 정해진 순서로 실행되는 다른 함수나 스크립트에 의존하고 있는 경우, 확실한 제어가 없으면 코드 사이에 타이밍 문제가 발생할 수 있습니다. 예를 들어 아래 함수는 일정 시간 동안 카운트다운을 실행한 다음 전달된 플레이어의 체력이 한계치보다 높으면 점수를 부여합니다.
CountdownScore(Player:agent)<suspends>:void=
# 일정 시간 동안 기다린 후 HP가 한계치보다 높은 각 플레이어에게 일정 점수를 부여합니다.
Sleep(CountdownTime)
if:
Character := Player.GetFortCharacter[]
PlayerHP := Character.GetHealth()
PlayerHP >= HPThreshold
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("This Player has score!")
CountdownScore(Player:agent)<suspends>:void=
# 일정 시간 동안 기다린 후 HP가 한계치보다 높은 각 플레이어에게 일정 점수를 부여합니다.
Sleep(CountdownTime)
if:
Character := Player.GetFortCharacter[]
PlayerHP := Character.GetHealth()
PlayerHP >= HPThreshold
then:
ScoreManager.Activate(Player)
# 대기 중인 코드가 실행되도록 이벤트에 신호를 보냅니다.
CountdownCompleteEvent.Signal()
이제 이 코드는 CountdownCompletedEvent()
가 신호를 받을 때까지 대기하므로, CountdownScore()
의 실행이 완료된 이후에만 플레이어의 점수를 확인하게 됩니다. 많은 장치에는 Await()
를 호출해 코드 타이밍을 제어할 수 있는 내장 이벤트가 있습니다. 이러한 이벤트를 자신의 커스텀 이벤트와 함께 활용하면 몇 가지 기능이 동시에 작동하는 복잡한 게임 루프를 만들 수 있습니다. 예를 들어 Verse 스타터 템플릿은 여러 커스텀 이벤트를 사용해 캐릭터 움직임을 제어하고, UI를 업데이트하고, 전체적인 게임 루프를 보드 단위로 관리합니다.
sync, race, rush로 여러 표현식 처리하기
sync, race, rush는 모두 여러 비동기 표현식을 한 번에 실행할 수 있게 해주며, 해당 표현식의 실행이 끝나면 다른 함수를 수행합니다. 각 표현식을 활용하면 각 비동기 표현식의 수명을 확실하게 관리하여 다양한 여러 상황을 처리할 수 있는 다이내믹한 코드를 만들 수 있습니다.
rush
표현식을 예를 들어보겠습니다. 이 표현식은 여러 비동기 표현식을 동시에 실행하지만 맨 먼저 완료된 표현식의 값만 반환합니다. 여러 팀이 과제를 완료해야 하는 미니게임이 있는데, 가장 먼저 완료한 팀은 다른 플레이어의 과제를 방해할 수 있는 파워업을 받게 된다고 가정해 보겠습니다. 각 팀의 과제 완료 시점을 추적하는 복잡한 타이밍 로직을 작성할 수도 있지만, rush
표현식을 활용할 수도 있습니다. 이 표현식은 처음 완료되는 비동기 표현식의 값을 반환하므로 승리 팀을 반환하고, 그러면서도 다른 팀을 처리하는 코드가 계속 실행되도록 합니다.
WinningTeam := rush:
# 비동기 함수 세 개가 동시에 시작합니다
RushToFinish(TeamOne)
RushToFinish(TeamTwo)
RushToFinish(TeamThree)
# 비동기 함수가 하나라도 완료되면 다음 표현식이 즉시 호출됩니다.
GrantPowerup(WinnerTeam)
race
표현식도 같은 규칙을 따르지만, 비동기 표현식이 완료되면 다른 표현식이 취소된다는 차이가 있습니다. 따라서 한 번에 여러 비동기 표현식의 수명을 제어할 수 있고, 이를 sleep()
표현식과 결합하면 표현식 실행 시간을 원하는 대로 제한할 수도 있습니다. 앞선 rush
예시를 다시 보겠습니다. 이번에는 한 팀이 승리하면 미니게임이 즉시 종료됩니다. 미니게임이 영원히 계속되지 않도록 타이머도 추가해야 합니다. race
표현식을 사용하면 이벤트나 다른 동시성 도구를 사용하지 않고도 경주에서 진 표현식을 취소해야 하는 시점을 알 수 있으므로, 두 기능을 모두 구현할 수 있습니다.
WinningTeam := race:
# 비동기 함수 4개가 동시에 시작합니다
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