서론: 멀티플레이 게임과 netcode
유니티에서 멀티플레이 게임을 만들기 위해서 netcode는 필수다.
나는 이전에 유니티에서 기본 제공하는 netcode for gameobject 를 사용하다가, 기본 제공 라이브러리의 성능과 기능 부족으로 인해, Fishnet 으로 migrate를 해 개발을 하는 중이다.
그리고, 개발이 진행되다 보니, fishnet의 기본 라이브러리 기능에도 부족함을 느끼고, 자주 쓰는 코드들을 편히 쓸 수 있도록 간단한 라이브러리를 만들어 보았다.
배경지식, 문제상황: NetworkObject와 싱글톤
내 프로젝트의 경우, 싱글 플레이를 위해 만들어졌던 코드를 멀티 플레이로 옮기는 방식으로 개발을 하고 있다.
그 과정에서 문제가 되었던 코드는 싱글톤이다.
나의 요구사항:
게임에는 플레이어마다 각각 하나씩만 가지고 있는 오브젝트나 인스턴스가 있다.
예를들면, Player stat이 대표적이다. 플레이어의 레벨, 체력, 돈, 경험치 등은 각 플레이어마다 단 하나씩만 가지고 있어야 한다. 그렇다면 이를 싱글톤처럼 처리하는것이 단연 옳다고 생각하기 쉽다.
문제 상황:
문제는 이 데이터들이 서버와 싱크가 되어야 하는 데이터라는 것이다.
서버와 싱크를 하기 위해서는 인스턴스가 NetworkObject로서 존재해야 한다.
NetworkObject는 모든 클라이언트, 그리고 서버가 공유하게 된다.
즉, 서버나 각 클라이언트는, 접속해 있는 모든 플레이어들의 PlayerStat 오브젝트를 각각 하나씩 모두 가지고 있다.
이렇게 되면, 기존에 싱글톤 기반으로 작성된 코드가 모두 무용지물이 된다.
더 이상 자신의 스텟 정보에 대해 PlayerStat.Instance로 접근할 수 없다. 또한 자신의 PlayerStat을 찾기 위해 매번 ClientID와 해당 NetworkObject의 OwnerID를 비교해야 한다.
이 과정은 매우 번거롭고 보일러 플레이트 코드를 만들게 되어, 코드를 쓸데없이 verbose하게 만든다.
해결: NetworkedSingletonFinder, NetworkedSingleton
개념:
Fishnet의 InstanceFinder에서 영감을 받았다. InstanceFinder는 네트워크 연결시 동적으로 생성되는 싱글톤 오브젝트들에 편리하게 접근할 수 있게 해 주는 싱글톤 형태의 코드다.
예를들면 InstanceFinder.ClientManager.Connection을 통해 local client의 connection데이터를 가져올 수 있다.
나는 이와 비슷하게 NetworkedSingletonFinder를 만들었다.
구조는 이와 같다. 만약 PlayerStatManager라는 싱글톤 개념처럼 쓰고 싶은 네트워크 오브젝트(인스턴스)가 있다고 해 보자.
public class PlayerStatFinder : NetworkedSingletonFinder<PlayerStatFinder, PlayerStatManager>
{
// blank
}
이와 같이 PlayerStatFinder 클래스를 만든다. Finder로서의 기능은 이미 제네릭으로 구현을 해 놓았기 때문에, 상속만 받으면 된다.
실제 PlayerStatManager는 다음처럼 작성한다.
public class PlayerStatManager : NetworkedSingleton<PlayerStatFinder, PlayerStatManager>
{
public int Money
{
get => _money.Value;
[Server]
private set
{
var validMoney = Mathf.Clamp(value, 0, MaxValue);
_money.Value = validMoney;
}
}
public int Level
{
get => _level.Value;
[Server]
private set
{
var validLevel = Mathf.Clamp(value, 1, MaxValue);
_level.Value = validLevel;
}
}
private readonly SyncVar<int> _money = new (new SyncTypeSettings(WritePermission.ServerOnly, ReadPermission.OwnerOnly));
private readonly SyncVar<int> _level = new (new SyncTypeSettings(WritePermission.ServerOnly, ReadPermission.OwnerOnly));
이제부터는 마치 싱글톤처럼 PlayerStatManager에 접근할 수 있다.
사용 예:
만약 클라이언트에서 실행되는 코드라면 PlayerStatFinder.Instance는 자신 소유의 PlayerStatManager를 자동으로 반환한다.
[Client]
public void TryBuy()
{
var monsterName = _currentDisplayingItem;
var playerStat = PlayerStatFinder.Instance;
// prediction
if (GetMonsterDataByName(monsterName).Cost <= playerStat.Money)
{
// request buy
TryBuyServerRpc(monsterName);
}
}
만약 서버에서 실행되는 코드라면 PlayerStatFinder.GetInstanceByConnection() 을 이용해서 원하는 Connection 대상의 Instance를 가져올 수 있다.
[ServerRpc]
private void TryBuyServerRpc(MonsterName monsterName, NetworkConnection client = null)
{
var playerStat = PlayerStatFinder.GetInstanceByConnection(client);
if (IsValidBuy(monsterName, playerStat, client))
{
playerStat.ChangeMoney(-GetMonsterDataByName(monsterName).Cost);
_currentItemAvailability = false;
// TargetRPC
PlayPurchaseSuccessFx(client);
SpawnGameMonsterTo(monsterName, client, new Vector3(8f, 0.2f, 13.5f));
}
else
{
// TargetRPC
PlayPurchaseFailureFx(client);
}
}
에러처리? => Assertion!
만약 외부로 판매하거나 할 목적의 코드였거나, closed source로 외부에서 사용될 일이 있는코드라면, Exception을 이용해 에러처리를 해야겠지만, 지금은 나 혼자, 나아가서도 최대 몇명의 팀원들끼리만 사용할 예정이기 때문에, Assertion을 사용했다. 게다가 Assertion은 나중에 빌드시에 성능을 갉아먹지도 않는다!
더 큰 문제....
그리고 이 방식으로 프로젝트를 이어가던 도중, 더 큰 설계적 문제에 봉착했다.
이에 대해서는 다음 포스트에서...