※2017년 3월 3일에 공개된 아래 블로그 기사를 번역하였습니다.
Looterkings: Re-connecting with Photon Bolt
이 초빙 포스트는 루터킹즈의 개발자 Frederik Reher가 작성한 것 입니다.
루터킹즈는 유튜버 Manuel Schmitt (SgtRumpel으로 알려짐), Erik Range (Gronkh으로 알려짐) 와 Valentin Rahmel (Sarazar로 알려짐) 이 2015년에 설립한 인디 스튜디오 입니다.
루터킹즈의 첫 번째 게임은 roguelike dungeon-crawler Looterkings으로 2016년 8월에 스팀의 얼리 억세스로 출시되었으며 현재 개발중입니다.
이 게임은 최대 4명의 플레이어가 게임할 수 있는 액션-멀티플레이어게임으로 플레이어는 고블린을 조작하여 작은 괴물을 죽이는 게임입니다.
Frederik씨는 이미 오픈되어 있는 게임세션에 재접속하는 것을 허용할 때 개발자들이 겪는 어려움과 이 문제들을 Photon Bolt 시스템에서는 어떻게 해결하는지에 대해서 설명하고 있습니다.
제약사항
“플레이어를 게임에 재접속시키면 어떨까?” 라고 게임 디자이너가 질문하였고 나의 작업이 시작되었습니다. 그때까지 우리는 루터킹즈의 멀티 플레이 모드가 더 이상 죽지않고 호스트와 클라이언트간에 주요한 데이터 비동기화가 발생하지 않도록 플레이어간의 인터랙션에 대해 안정화 시켰습니다.
마침내 2명의 플레이어가 고블린을 합체하여 더 많은 피해와 다른 혜택을 제공 할 수 있는 스태킹 기능을 구현했습니다. 이 기능을 통해 별도로 분리되어 있는 플레이어가 제어하는 장치 2대간의 애니메이션 동기화를 구현했습니다. 얼마나 힘들었을까요?
음, 사실대로 말하면 꽤 힘들었습니다. 만약 우리가 볼트를 사용하지 않았었다면 말이죠. 인터렉션 시스템을 디자인 했을 때, 재접속에 대해서는 생각하지 않았었습니다. 빠르고 문제없이 동작이 되도록 하는 것이 최우선 과제였습니다. BoltEntity들이 어수선해지는 것을 방지하기 위해서 시스템은 글로벌 이벤트에 매우 많이 의존하는 것으로 작업을 하였습니다. 고블린이 주울 수 있는 모든 버섯들은 동기화된 이벤트를 사용하였으며 상자, 배럴, 문, 그리고 상점도 마찬가지 입니다.
구원자 - 볼트의 토큰 시스템
지금까지 발생되었던 모든 것들에 대해 클라이언트에게 어떻게 알려주어야 할까요? 다른 네트워킹 솔루션에서는 인터렉티브 객체별로 RPC를 전송했었거나 더 좋지 않은 방법인 객체들을 네트워크 엔티티들로 만들었을지도 모릅니다. 하지만 고맙게도 볼트에는 토큰이 있습니다.
토큰(볼트에서는 IProtocolToken으로 명명되어 있음)은 볼트에서 임의의 데이터를 직렬화하는 방법입니다. 토큰은 UdpPacket을 수용하기 위한 Read와 Write 메소드가 있습니다. UdpPacket은 표준 데이터 타입들의 직렬화와 비직렬화 메소드들을 제공합니다. 이러한 메소드를 사용한 데이터 구조는 상황에 따라 정의하면 됩니다. 볼트에서는 거의 모든 것에 토큰을 추가할 수 있습니다.
연결시도를 하거나 호스트에 암호문을 전송하고 유닛을 스포닝하고 파라미터들을 전달하던지 간에 첨부 토큰은 매우 유용합니다. (저는 왜 유니티가 Instantiate 메소드에 유사한 기능을 왜 구현을 해놓지 않았는지 궁금합니다.)
토큰은 엔티티 상태, 플레이어 커맨드에 추가될 수 있으며 가장 중요한 것을 이벤트에 추가 할 수 있다는 것 입니다.
public class MyToken : IProtocolToken
{
public void Read( UdpPacket packet )
{
// deserialize data here
}
public void Write( UdpPacket packet )
{
// serialize data here
}
}
게임 실행중의 데이터
게임 실행중에 연결된 클라이언트는 어떤 데이터가 필요할까요? 클라이언트는 먼저 레벨을 어떻게 구축할 것인지를 알아야 합니다. 루터킹즈의 레벨들은 사전에 만들어진 룸, 인터섹션과 데드엔드에서 절차적으로 구축됩니다. 레벨이 로드된 후 클라이언트가 첫 번째로 받는 토큰은 레벨 시트와 토큰 입니다. 게임 모드에 따라서 클라이언트가 수신하는 다음 토큰에는 플레이어가 영구적인 버프를 얻기 위해 시도 할 수있는 특수 임무에 대한 데이터가 들어 있습니다. 두 토큰 모두 서로 다른 이벤트를 관리 할 수 있도록 하는 단일 유형의 이벤트만 필요로합니다. 다음 이벤트는 레벨의 현재 상태에 대한 정보를 포함하는 것을 전송합니다. 레벨들에는 플레이어가 상호 작용하고 진행함에 따라 변화하는 여러 부분이 있습니다. 이 중에서 가장 중요한 부분은 인터렉티브 객체들입니다. 나무 상자가 파괴되고 버섯을 먹고 상자가 열려 진 것을 확실히 하기 위해서 모든 인터렉티브 객체의 상태를 직렬화 해야합니다. 인터렉티브 객체는 모두 상호 작용하는지 여부를 나타내는 플래그와 객체가 현재 두 개 이상의 상태를 가졌을 때 현재 상태를 나타내는 바이트인 ID를 가지고 있습니다. 예를들면, 기본방향을 사용하는 퍼즐같은 것이죠.
또한 룸과 문에 대한 정보를 전송해야합니다. 플레이어가 아직 방문하지 않은 룸으로 향하는 문은 안개가 낀 흰색 프레임으로 표시합니다. 문을 열고 닫는 것도 이벤트별로 수행됩니다. 즉, 문을 열었는지 여부와 방을 이전에 방문했는지 여부에 대한 데이터를 보내야 한다는 것 입니다.
마지막으로 타 플레이어들이 전송해야하는 것에 대한 정보도 있습니다. 소개에서 언급했듯이, 두 마리의 도깨비가 서로 쌓아 올려 하나의 유닛을 만들 수 있습니다. 새로운 플레이어는 누구와 누가 겹쳐졌으며 누가 위에 있는지 알 필요가 있습니다.
또한 플레이어는 레벨 업마다 파티클 효과로 둘러싸여 있습니다. 첨부 토큰에 플레이어의 현재 경험치를 보내지만 새로운 플레이어가 이미 몇 분 동안 실행중인 레벨에 연결되어 있으므로 원하지 않는 파티클 동작이 발생하지 않도록 다른 플레이어의 현재 경험치를 제공해야합니다. 따라서 최종 Read 와 Write 메소드는 다음과 같습니다:
public void Read( UdpPacket packet )
{
// entitystatuses (ID, isUsable, state)
entityStatuses = new NetworkEntityStatus[ packet.ReadInt() ];
for ( int i = 0; i < entityStatuses.Length; i++ )
{
entityStatuses[ i ] = new NetworkEntityStatus(
packet.ReadInt( NETWORK_ENTITY_ID_BITS ),
packet.ReadBool(),
packet.ReadByte() );
}
// doorStatuses (ID, isOpen, hasBeenOpen)
doorStatuses = new DoorOpenStatus[ packet.ReadByte() ];
for ( int i = 0; i < doorStatuses.Length; i++ )
doorStatuses[ i ] = new DoorOpenStatus( packet.ReadByte(), packet.ReadBool(), packet.ReadBool() );
// roomStatuses (ID, isCleared)
roomStatuses = new RoomStatus[ packet.ReadByte() ];
for ( int i = 0; i < roomStatuses.Length; i++ )
sectorStatuses[ i ] = new RoomStatus( packet.ReadByte(), packet.ReadBool() );
// playerStackPartnerIds (positive = on top)
playerStackPartnerIds = new int[ 4 ];
for ( int i = 0; i < 4; i++ )
playerStackPartnerIds[ i ] = packet.ReadByte() - 4; // shift by -4 to revert +4 on write
// playerCrawlStatuses (-1 = dc, 0 = dead, 1 = alive)
playerCrawlStatuses = new int[ 4 ];
for ( int i = 0; i < 4; i++ )
playerCrawlStatuses[ i ] = packet.ReadByte() - 1; // shift by -1 to revert +1 on write
// playerExps
playerExps = new int[ 4 ];
for ( int i = 0; i < 4; i++ )
playerExps[ i ] = packet.ReadInt();
}
public void Write( UdpPacket packet )
{
// entityStatuses (ID, isUsable, state)
packet.WriteInt( entityStatuses.Length );
for ( int i = 0; i < entityStatuses.Length; i++ )
{
packet.WriteInt( entityStatuses[ i ].id, NETWORK_ENTITY_ID_BITS );
packet.WriteBool( entityStatuses[ i ].isUsable );
packet.WriteByte( entityStatuses[ i ].state );
}
// doorStatuses (ID, isOpen, hasBeenOpen)
packet.WriteByte( (byte)doorStatuses.Length );
for ( int i = 0; i < doorStatuses.Length; i++ )
{
packet.WriteByte( (byte)doorStatuses[ i ].id );
packet.WriteBool( doorStatuses[ i ].isOpen );
packet.WriteBool( doorStatuses[ i ].hasBeenOpen );
}
// roomStatuses (ID, isCleared)
packet.WriteByte( (byte)roomStatuses.Length );
for ( int i = 0; i < roomStatuses.Length; i++ )
{
packet.WriteByte( (byte)roomStatuses[ i ].id );
packet.WriteBool( roomStatuses[ i ].isCleared );
}
// playerStackPartnerIds (positive = on top)
for ( int i = 0; i < 4; i++ )
packet.WriteByte( (byte)( playerStackPartnerIds[ i ] + 4 ) ); // shift +4 so stack lower does not get lost
// playerCrawlStatuses (-1 = dc, 0 = dead, 1 = alive)
for ( int i = 0; i < 4; i++ )
packet.WriteByte( (byte)( playerCrawlStatuses[ i ] + 1 ) ); // shift by +1 so -1 does not get lost
// playerExps
for ( int i = 0; i < 4; i++ )
packet.WriteInt( playerExps[ i ] );
}
이제는 클라이언트가 주변 세계가 어떻게 보이는지 알 수 있습니다. 클라이언트 자체에 대한 정보 하나만 누락되었습니다. 각 플레이어는 실행 중에 여러개의 무기, 의상 및 모자를 구입할 수 있습니다. 서버 및 로컬 플레이어만 플레이어가 소유 한 항목을 알고 있으며 이 정보는 상점의 항목이 표시되는 방식을 변경합니다. 따라서 장비를 수동으로 동기화해야합니다. 기본적으로 바이트 인 ItemID 배열을 직렬화하면 쉽게 처리 할 수 있습니다.
요약
그래서 우리는 볼트의 토큰 시스템을 사용하여 플레이어를 다시 연결하는 문제를 해결했습니다. 타 네트워킹 솔루션과 유사한 기능을 구현할 수 있을까요? 가능합니다. 모든 인터렉티브 객체를 BoltEntity의 일부 형식으로 변경해야 할 필요가 있었습니까? 가장 확실합니다. 하지만 토큰에는 단점과 한계가 있다는 것을 알아야 합니다. 토큰이 커지고 이벤트가 커진 토큰 보내기를 거절하여 게임을 효과적으로 중단 시켰음을 알게되었습니다. 간단히 말해서, 토큰은 매우 유용한 도구이며 루터킹즈를 더 쉽게 만드는 프로세스를 만들었습니다.
작성자 Robert
댓글
댓글 0개
댓글을 남기려면 로그인하세요.