새소식

인기 검색어

게임 개발/유니티

커스텀 라이브러리를 만든 경험 - 1

  • -

서론: 멀티플레이 게임과 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은 나중에 빌드시에 성능을 갉아먹지도 않는다!

더 큰 문제....

그리고 이 방식으로 프로젝트를 이어가던 도중, 더 큰 설계적 문제에 봉착했다.

이에 대해서는 다음 포스트에서...

Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.