개발 낙서장

[Unity2D] 포톤 서버로 멀티 구현 본문

Unity/Devlog

[Unity2D] 포톤 서버로 멀티 구현

권승준 2023. 2. 20. 23:38

구현 동기

유니티 2D를 공부하면서 간단한 멀티 시스템도 같이 공부해보자 싶어서 포톤 서버로 2D MMORPG 쪽으로 만들어보자고 생각했다.
2D MMORPG 하면 정말 여러 가지 게임이 있지만 가장 대표적으로는 역시 메이플스토리가 가장 먼저 떠오른다.

지금은 추억의 사진인 예전 메이플스토리의 서버, 채널 선택 화면이다

메이플 스토리의 멀티 시스템은 메인 로비에서 서버(스카니아, 제니스 등등)를 선택하고 채널을 선택한 이후 캐릭터를 선택하면 게임을 플레이할 수 있다.
나는 기초적인 멀티 시스템만 구현할 것이기 때문에 채널을 선택해서 게임 화면에 진입하는 것까지만 구현하려고 한다.

채널

유니티에서 간편하게 사용할 수 있는 서버 패키지인 포톤이 있다. 패키지를 프로젝트에 임포트한 후 가이드에 따라 사용하면 된다.

포톤 서버는 기본적으로 "룸"을 기반으로 동작하는 네트워크이다. 특별한 방법을 사용하지 않는 이상 룸에 들어가지 않으면 클라이언트 간 통신이 불가능하므로 반드시 룸을 사용해야 한다.
포톤 서버에 접속하면 별도의 설정이 없는 한 자동으로 로비에 입장되고 로비에 입장한 이후 방에 Join할 수 있다. 나는 여기서 방의 개념을 메이플스토리의 채널과 같은 개념으로 설정하고 구현할 것이다.

우선 게임을 플레이하는 데 있어 언제든 접속하고 나갈 수 있어야 하므로 NetworkManager를 만들었다.
게임이 시작되면 ConnectUsingSettings 함수를 실행해 포톤 서버에 접속한다. 이후 채널을 선택하고 접속 버튼을 누르면 해당 채널의 번호를 방 이름으로 해서 JoinOrCreateRoom 함수를 실행해 방에 접속하게 된다.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Photon.Pun;
using Photon.Realtime;
using System;
using UnityEngine.Events;

public class NetworkManager : MonoBehaviourPunCallbacks, IConnectionCallbacks
{
    public static NetworkManager Instance;

    public UnityEvent JoinedLobbyEvent, JoinedRoomEvent, LeftLobbyEvent, LeftRoomEvent;

    [HideInInspector] public string _roomName;

    private void Awake()
    {
        if(Instance == null)
        {
            Instance = this;
            DontDestroyOnLoad(this);
        }
        else
        {
            if (Instance != this)
                Destroy(this);
        }

        Screen.SetResolution(960, 540, false);
        PhotonNetwork.SendRate = 60;
        PhotonNetwork.SerializationRate = 30;
        PhotonNetwork.AutomaticallySyncScene = true;
    }

    private void Start()
    {
        PhotonNetwork.ConnectUsingSettings();
    }

    private void Update()
    {
        if (Input.GetKeyDown(KeyCode.Escape) && PhotonNetwork.IsConnected)
            PhotonNetwork.Disconnect();
    }

    public void Connect() => PhotonNetwork.ConnectUsingSettings();

    public override void OnConnectedToMaster()
    {
        Debug.Log("서버 연결");

        PhotonNetwork.JoinLobby();
    }

    public override void OnJoinedLobby()
    {
        Debug.Log("로비 연결");
        JoinedLobbyEvent.Invoke();
    }

    public override void OnJoinedRoom()
    {
        JoinedRoomEvent.Invoke();
        Spawn();
    }

    public override void OnJoinRoomFailed(short returnCode, string message)
    {
        Debug.LogFormat("{0}\n{1}", returnCode, message);
    }

    public override void OnLeftLobby()
    {
        Debug.Log("로비 나감");
        LeftLobbyEvent.Invoke();
    }

    public override void OnLeftRoom()
    {
        Debug.Log("방 나감");
        LeftRoomEvent.Invoke();
    }

    public void JoinOrCreateRoom(string roomName)
    {
        PhotonNetwork.JoinOrCreateRoom(roomName, new RoomOptions { MaxPlayers = 5 }, null);
        _roomName = roomName;
    }

    public void LeaveRoom()
    {
        if (PhotonNetwork.InRoom)
            PhotonNetwork.LeaveRoom();
    }

    public void Spawn()
    {
        PhotonNetwork.Instantiate("Prefabs/Player", Vector3.zero, Quaternion.identity);
    }
}

이렇게 하면 아주 기본적인 포톤 네트워크를 사용할 수 있다. 그럼 다음으로 채널 선택 화면을 구현하자.

네트워크 매니저를 구현했으면 이건 아주 간단하다. 채널 버튼들을 List로 관리하고 채널을 선택 후 접속 버튼을 누르면 선택된 채널의 번호를 이름으로 하는 방으로 들어가면 된다.

using Photon.Pun;
using System.Collections;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.EventSystems;
using UnityEngine.SceneManagement;
using UnityEngine.UI;

public class ChannelSelectManager : MonoBehaviourPunCallbacks
{
    [SerializeField] List<Button> _channels = new List<Button>();
    [SerializeField] Button _joinButton, _leaveButton;
    [SerializeField] Text _sellectedChannel;
    [SerializeField] InputField _nickNameInput;

    private void Start()
    {
        foreach (var c in _channels)
            c.interactable = false;
        _joinButton.interactable = false;
        _leaveButton.interactable = false;

        NetworkManager.Instance.JoinedLobbyEvent.AddListener(ActiveChannels);
    }

    public void ActiveChannels()
    {
        foreach(var c in _channels)
            c.interactable = true;
        _joinButton.interactable = true;
    }

    public void OnClickedChannel()
    {
        Text channel = EventSystem.current.currentSelectedGameObject.GetComponentInChildren<Text>();

        if (channel != null)
            _sellectedChannel.text = channel.text;
        else
            _sellectedChannel.text = "null";
    }

    public void OnClickedJoin()
    {
        if (_sellectedChannel.text.Equals("null"))
            return;

        if (!_nickNameInput.text.Equals(""))
        {
            NetworkManager.Instance.JoinOrCreateRoom(_sellectedChannel.text);
            PhotonNetwork.LocalPlayer.NickName = _nickNameInput.text;
            PhotonNetwork.LoadLevel("SampleScene");
        }
    }
}

다만 여기서 주의사항이 있는데 렉이 걸려서 혹은 사용자가 너무 빨라서 로비에 완전히 접속하기 전에 Room에 Join하게 되면 에러가 발생하면서 콜백 함수인 OnJoinRoomFailed 함수가 실행된다.
해당 함수 내에서 후처리 또한 반드시 해야 하고 애초에 에러가 발생하는 것을 방지하기 위해 로비에 완전히 접속하면 접속 버튼을 활성화하게 해 주었다.
이것은 네트워크 매니저에 이벤트를 생성하고 버튼을 활성화하는 함수를 게임이 시작될 때 등록하여 OnJoinLobby 함수가 실행되면 자동으로 실행되게 하였다.

실시간 멀티 플레이

채널을 선택해 접속하면 이제 게임을 플레이할 수 있어야 한다. 싱글 플레이 게임의 경우 오로지 플레이어를 기준으로만 모든 것을 구현하면 됐었는데 여기서 멀티 플레이가 돼서 사람이 한 명이라도 더 생기는 순간 되게 복잡해진다. 사소한 것 하나하나 네트워크를 통해 동기화해줘야 하고 제대로 동기화되지 않았을 경우를 대비하는 코드도 작성하는 등 사소한 변수 하나하나도 쉽게 지나칠 수 없었다.

그럼 가장 중요한 것이 '동기화'인데 네트워크 동기화를 내가 직접 한다면 아마 머리가 터질 것이다. 포톤에서는 동기화를 해주는 컴포넌트도 제공하는데, 그게 'PhotonView'이다.
포톤은 내 클라이언트에서는 내 캐릭터 본체를 조작하는 것이지만 다른 클라이언트가 보는 내 캐릭터는 내 본체 캐릭터의 클론이다. 즉 내 본체가 이동해도 내 클론이 이동하지 않으면 서로 전혀 동기화되지 않는 것이다. 이것을 포톤뷰가 해결해 준다.

포톤 뷰를 이용해 동기화하는 방법 중 내가 알고 있는 방법으로 두 가지가 있는데 하나는 RPC 함수를 이용하는 것이고 다른 하나는 OnPhotonSerializeView 함수를 이용하는 것이다.

RPC는 쉽게 말해 내가 원할 때만 함수를 실행시키는 이벤트와 비슷한 방식으로 동기화하는 것이고 OnPhotonSerializeView 함수는 Update처럼 주기적으로 계속 실행되면서 동기화하는 것이다.
즉 굳이 매 순간 동기화할 필요가 없다면 RPC를, 매 순간 실시간 동기화가 필요하다면 OnPhotonSerializeView를 이용하면 된다.

개념과 사용 방법은 인터넷에 널렸으니 난 이걸 어떻게 사용하여 구현했는지만 작성하겠다

우선 어떤 것을 어떻게 동기화할지 나누는 것이 중요하다. 각 유닛의 위치 정보는 반드시 항상 알고 있어야 하므로 상시 동기화를 해주고 이동, 공격 등 특정 조건에 의해 발생하는 정보들은 RPC로 동기화하는 것이 좋겠다.

    public virtual void Move(float moveX)
    {
        // AllBuffered로 하는 이유는 재접속시 동기화해주기 위해서
        // RpcTarget.All 이면, 호출되고 잊어버림.
        // RpcTarget.AllBuffered 이면, 호출되고 버퍼에 저장됨. 이후에 누가 들어오면 자동으로 순차적으로 실행된다.
        // 버퍼에 너무 많이 저장되면, 네트워크가 약한 클라이언트는 끊어질 수 있다고 한다.
        _photonView.RPC("MoveXRPC", RpcTarget.AllBuffered, moveX);

        _rigidbody.AddForce(Vector2.right * moveX * _moveSpeed, ForceMode2D.Impulse);
        if (_rigidbody.velocity.x > _maxSpeed)
            _rigidbody.velocity = new Vector2(_maxSpeed, _rigidbody.velocity.y);
        else if (_rigidbody.velocity.x < -_maxSpeed)
            _rigidbody.velocity = new Vector2(-_maxSpeed, _rigidbody.velocity.y);
    }

    [PunRPC]
    public virtual void MoveXRPC(float moveX)
    {
        transform.localScale = new Vector2(-moveX, 1f);
    }

    public virtual void Move(Transform target)
    {
        float moveX = 0f;
        if (target.position.x - transform.position.x > 0f)
        {
            moveX = 1f;
            Move(moveX);
        }
        else if (target.position.x - transform.position.x < 0f)
        {
            moveX = -1f;
            Move(moveX);
        }
    }

    public void Jump()
    {
        RaycastHit2D hit = Physics2D.Raycast(transform.position, Vector2.down, 0.5f, 1 << LayerMask.NameToLayer("Ground"));
        if (hit)
        {
            _photonView.RPC("JumpRPC", RpcTarget.All);
        }
    }

    [PunRPC]
    public virtual void JumpRPC()
    {
        _rigidbody.AddForce(Vector2.up * _jumpForce, ForceMode2D.Impulse);
        _isGrounded = false;
    }

    public void OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info)
    {
        if (stream.IsWriting)
        {
            stream.SendNext(transform.position);
        }
        else
        {
            _curPos = (Vector3)stream.ReceiveNext();
        }
    }

UnitController 스크립트의 일부이다. transform.position은 OnPhotonSerializeView 내부에서 계속 동기화해 주고 Move나 Jump처럼 특정 조건에 의해 발생하는 동작들은 RPC 함수로 구현해 동기화해 주었다.

그럼 다음으로 이것을 상속한 PlayerController와 EnemyController를 수정해야 한다.

먼저 플레이어는 Input을 통해 동작이 수행되므로 해당 오브젝트가 내가 조작하는 오브젝트인지를 구분해야 한다.
그건 PhotonView에 있는 IsMine이라는 bool 값으로 확인할 수 있는데 클라이언트에서 해당 오브젝트가 내 소유의 오브젝트이면 true를, 아니면 false를 반환한다. 이것을 이용해 photonView.IsMine == true일 경우에만 내 캐릭터를 조작하게 하면 된다.

    protected override void Awake()
    {
        base.Awake();
        _stateDic.Add(State.IDLE, new PlayerIdle(this));
        _stateDic.Add(State.MOVE, new PlayerMove(this));
        _stateDic.Add(State.ATTACK, new PlayerAttack(this));
        EnterState(State.IDLE);

        transform.Find("Canvas").gameObject.TryGetComponent(out _canvas);
        _nickName.text = _photonView.IsMine ? PhotonNetwork.NickName : _photonView.Owner.NickName;
        _nickName.color = _photonView.IsMine ? Color.green : Color.red;

        if (_photonView.IsMine)
        {
            GameObject.Find("CMvcam").TryGetComponent(out CinemachineVirtualCamera cm);
            cm.Follow = transform;
            cm.LookAt = transform;
        }

        foreach(Transform i in GetComponentsInChildren<Transform>())
        {
            if(i.name == "Weapon")
            {
                i.gameObject.TryGetComponent(out _weaponCol);
                //if (_weaponCol && _weaponCol.isActiveAndEnabled)
                //    _weaponCol.enabled = false;
                break;
            }
        }
    }

    protected override void Update()
    {
        base.Update();
        if (_photonView.IsMine)
        {
            if (Input.GetKey(KeyCode.LeftAlt) && IsGrounded)
            {
                EnterState(State.JUMP);
            }
            if (Input.GetButton("Attack"))
                Attack();
        }
    }

이렇게 하면 내 캐릭터는 인풋에 의해 조작되고 다른 유닛들은 UnitController에서 구현한 동기화에 의해 클라이언트에서 각자의 움직임대로 동기화되어 보이게 된다.

    public void OnFixedUpdate()
    {
        if (_unitController._photonView.IsMine)
        {
            if (_unitController._rigidbody.velocity.y < -0.01f || _unitController._rigidbody.velocity.y > 0.01f)
                _unitController.IsGrounded = false;
            else if (_unitController._rigidbody.velocity.y <= 0.01f && _unitController._rigidbody.velocity.y >= -0.01f)
                _unitController.IsGrounded = true;
        }
    }

그리고 이건 UnitJump라는 점프 상태 스크립트의 일부인데 IsMine을 이런 식으로 사용해도 된다. 꼭 조작 같은 부분이 아니더라도 나 자신만이 컨트롤해야 하는 변수인 경우에도 IsMine 조건을 달아주면 다른 클라이언트에서 내 오브젝트의 변수를 변경할 수 없을 것이다.

빌드하고 실행한 결과이다. a, b, c, d 유저가 있고 각각 1, 1, 2, 1 채널에 접속했다. a, b, d 유저는 1채널에(포톤 상에서 Room "1") 접속해 서로 보이고 동기화가 되지만 c유저는 2채널에 혼자 있기 때문에 혼자만 있는 모습이다.

적 유닛 동기화

적 유닛을 스폰하고 동기화하는 부분에 있어서 며칠을 고민하고 고생했다. 싱글 플레이로 구현하면 정말 쉬운 부분도 멀티 서버를 생각하서 구현하려니 신경 써야 할 것이 너무 많았고 어려웠다.

일단 나는 적 유닛을 지정된 위치에 스폰되게 한 후, 일정 주기마다 랜덤한 방향으로 이동과 멈춤을 반복하게 했다.
그냥 싱글 플레이에서 구현하고자 하면 정말 쉬운 부분이다. 몬스터 스포너를 만들어서 적 오브젝트를 갖고 와서 Instantiate하고 각 유닛은 각 동작을 수행하면 된다.

하지만 멀티 게임에서는 다르다. 나는 서버 클라이언트가 따로 있는 것이 아니기 때문에 유저 클라이언트에서 동기화를 해줘야 한다. a유저가 먼저 접속해 게임을 진행하다가 이후에 b유저가 접속했을 때 게임 내 데이터들을 제대로 동기화해주지 않으면 a유저 클라이언트가 갖고 있는 게임의 데이터와 b유저 클라이언트가 갖고 있는 데이터가 달라지게 되는 대참사가 일어난다. 이게 만약 c유저, d유저,..., aaaa유저 등 수십수백 수천 유저 클라이언트가 각자 다른 데이터를 가지게 된다면? 아마 게임은 터질 것이다...

우선 첫 번째로 고생했던 부분은 몬스터 스폰이다. 나는 서버 클라이언트가 없기 때문에 게임이 처음 실행되면 몬스터가 스폰되어 해당 오브젝트들을 List에 담아 동기화해 주면서 관리할 생각이었다. 그래서 대충 MonoBehaviourPunCallbacks를 상속받는 MonsterSpawner를 만들었고 몬스터를 스폰하는 RPC 함수를 통해 몬스터 스폰 정보를 동기화해 주면 되겠구나!라고 생각했다.

    public override void OnJoinedRoom()
    {
        if (PhotonNetwork.IsMasterClient)
        {
            photonView.RPC("SpawnMonsters", RpcTarget.AllBuffered);
        }
    }

    [PunRPC]
    public void SpawnMonsters()
    {
        _monsterData = _curMap._monsterSpawn;

        for (int i = 0; i < _monsterData.Count; i++)
        {
            MonsterData monsterData = _monsterData[i]._monster;
            GameObject monsterObject = PhotonNetwork.Instantiate(monsterData._objectPath, _monsterData[i]._position, Quaternion.identity);
            monsterObject.transform.SetParent(transform);
            monsterObject.transform.name = monsterData.name + (i + 1).ToString();
            _objects.Add(monsterObject);
        }
    }

언뜻 보면 정상적으로 실행될 것처럼 보인다. 방에 접속해야 포톤 통신을 할 수 있으므로 Start가 아니라 OnJoinedRoom이 실행되면 스폰 함수를 실행되게 했고 이 함수는 RPC 함수이므로 내부에서 변경되는 정보들은 전부 동기화가 될 것이다. 이대로 실행을 해보니 짜잔! 클라이언트가 접속할 때마다 몬스터가 스폰되어 맵이 난장판이 되었다.

네트워킹 개념이 부족한 나로서는 처음엔 도저히 이해할 수가 없었다. RPC로 동기화했고, MasterClient일 경우에만 실행되게 했으니 딱 한 번만 실행돼야 하는 것 아닐까? 하며 이런저런 변수를 만져보며 시도했지만 전부 실패했다.

그래서 조금 찾아보면서 생각해 본 결과 내가 잘못된 방식으로 접근하고 있었다는 것을 알 수 있었다. 결국 오브젝트 리스트를 동기화하는 것도 중요하지만 '이 스포너는 이미 스폰을 했는가?'에 대한 정보도 동기화해 주어야 이후에 접속한 클라이언트에서 중복 스폰을 방지할 수 있던 것이었다.

그래서 두 가지 방법을 찾을 수 있었는데 하나는 CustomProperties를 이용하는 것이다. 커스텀 프로퍼티는 해시 테이블로 구성돼 있는데 룸이나 플레이어에 특정 값을 부여해 구분하기 위한 용도로 사용된다. 근데 맵이 한 개도 아니고 나중에 게임을 확장하게 된다면 수십 개가 될 수도 있는데 이 맵들의 스폰 데이터를 전부 프로퍼티화하기에는 많이 무리일 것이라고 생각했다.
그래서 두 번째로는 몬스터 스포너에 포톤 뷰를 달아서 스포너 또한 하나의 멀티 플레이 객체로써 동기화하는 것이다. 이 방법은 정말 간단하다. isFirstSpawn이라는 bool 변수를 선언하고 이 함수가 true인지 false인지에 따라 스폰을 실행해 주면 되는 것이다.

    public override void OnJoinedRoom()
    {
        if (PhotonNetwork.IsMasterClient && !_isFirstSpawn)
        {
            photonView.RPC("SetFirstSpawn", RpcTarget.OthersBuffered);
            photonView.RPC("SpawnMonsters", RpcTarget.AllBuffered);
        }
    }

    [PunRPC]
    public void SetFirstSpawn() => _isFirstSpawn = true;

    [PunRPC]
    public void SpawnMonsters()
    {
        if (!_isFirstSpawn)
        {
            _monsterData = _curMap._monsterSpawn;

            for (int i = 0; i < _monsterData.Count; i++)
            {
                MonsterData monsterData = _monsterData[i]._monster;
                GameObject monsterObject = PhotonNetwork.Instantiate(monsterData._objectPath, _monsterData[i]._position, Quaternion.identity);
                monsterObject.transform.SetParent(transform);
                monsterObject.transform.name = monsterData.name + (i + 1).ToString();
                _objects.Add(monsterObject);
            }
        }
    }

처음엔 포톤에 대한 개념이 너무 부족해서 이런 간단한 방법조차 생각해 낼 수 없었다. 어쨌든 이렇게 하고 실행하면 처음 접속한 클라이언트에서는 _isFirstSpawn이 False이니 RPC함수들이 실행되면서 버퍼에 저장하고 이후에 접속한 클라이언트들은 룸에 접속하면 먼저 버퍼에 저장된 값들을 꺼내와 동기화하니 _isFirstSpawn값이 True이기 때문에 중복 스폰이 되지 않는 것이다.

두 번째로 고생한 부분은 몬스터의 상태 패턴 내부 변수를 동기화하는 것이었다. 이 역시도 고민하다 너무 간단하게 해결돼서 조금 허무하긴 했다.
먼저 모든 몬스터의 처음 상태는 MonsterIdle이고 이후 랜덤한 시간마다 MonsterMove 상태로 넘어가게 된다. MonsterMove 상태에서는 랜덤한 시간 동안 랜덤한 방향으로 움직이고 다시 MonsterIdle 상태로 돌아가는 것을 반복한다. 이렇게 하면 가만히 제자리에만 있는 것이 아니라 각자가 다른 움직임을 갖게 된다.

그럼 랜덤값을 기반으로 상태가 변화하기 때문에 시간값, 방향값 등을 조절해주어야 한다.

using Photon.Pun;
using System.Collections;
using System.Collections.Generic;
using Unity.Burst.CompilerServices;
using UnityEngine;

public class EnemyIdle : IState
{
    EnemyController _unitController;

    float _range = 5f;
    float _count, _moveDuration;

    public EnemyIdle(EnemyController unit)
    {
        _unitController = unit;
    }

    public void OnEnter()
    {
        _unitController.PlayAnimation(State.IDLE);
        _count = 0f;
        _moveDuration = Random.Range(3, 7);
    }

    public void OnExit()
    {
        _count = 0f;
    }

    public void OnUpdate()
    {

    }

    public void OnFixedUpdate()
    {
        if (_unitController.photonView.IsMine)
        {
            _count += Time.fixedDeltaTime;
            if (_count >= _moveDuration)
            {
                _unitController.ExitState(State.IDLE);
                _unitController.EnterState(State.MOVE);
            }
        }
    }

이게 처음 코드인데 당연히 동기화가 될 리가 없었다. 처음 접속한 클라이언트에서는 당연히 정상적으로 작동한다. 근데 이후에 클라이언트가 접속할 때마다 각 클라이언트의 변수값이 초기화되면서 뒤죽박죽으로 상태가 변화했다. 그래서 생각한 게 각 상태가 MonoBehaviourPunCallbacks를 상속하게 하여 RPC 함수를 실행해 주면 되겠구나! 싶었다. 결과는 무참히 실패했다.

IState는 인터페이스라 따로 컴포넌트를 달아주는 게 불가능했기에 둘 다 상속받게 하고 RPC 함수를 만들어 동기화해주고자 했다. 하지만 RPC 함수를 사용하려면 해당 객체가 PhotonView를 갖고 있어야 했다. 그래서 _unitController.photonView.RPC로 해보려고 했지만 이것 또한 당연히 실패.

그래서 생각한 게 '그럼 각 상태마다 포톤 뷰를 달아주면 되지 않을까?'였다. IState는 유니티 엔진 상에서 따로 객체로써 다룰 수 없었기 때문에 미리 포톤뷰를 추가하는 것은 불가능했기에 Awake에서 AddComponent로 포톤뷰를 달아주었다. 여기서는 진짜 될 줄 알았는데 AddComponent로 포톤뷰를 달아주기만 하고 따로 초기화를 해주지 않으면 포톤뷰 내부 값들이 null이 되어 동작하지 않았다.

미칠 노릇이었지만 내 개념이 부족한 탓이니 열심히 구글링을 했다. 스크립트를 동기화하려면 포톤 뷰를 가져서 직접 RPC 동기화를 하거나, IPunObservable을 통해 동기화하는 방법 두 가지가 있다고 했다. IState는 포톤뷰를 직접 가질 수 없었으므로 IPunObservable을 사용해 동기화해 주었다. UnitController의 포톤뷰를 가져와 사용하는 방법도 있을 수는 있지만 조금 불안한 방법이라 생각했다.

using Photon.Pun;
using System.Collections;
using System.Collections.Generic;
using Unity.Burst.CompilerServices;
using UnityEngine;

public class EnemyIdle : IState, IPunObservable
{
    EnemyController _unitController;

    float _range = 5f;
    float _count, _moveDuration;

    public EnemyIdle(EnemyController unit)
    {
        _unitController = unit;
    }

    public void OnEnter()
    {
        _unitController.PlayAnimation(State.IDLE);
        _count = 0f;
        _moveDuration = Random.Range(3, 7);
    }

    public void OnExit()
    {
        _count = 0f;
    }

    public void OnUpdate()
    {
    }

    public void OnFixedUpdate()
    {
        if (_unitController.photonView.IsMine)
        {
            _count += Time.fixedDeltaTime;
            if (_count >= _moveDuration)
            {
                _unitController.ExitState(State.IDLE);
                _unitController.EnterState(State.MOVE);
            }
        }
    }

    public void OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info)
    {
        if(stream.IsWriting)
        {
            stream.SendNext(_count);
            stream.SendNext(_moveDuration);
        }
        else if(stream.IsReading)
        {
            _count = (float)stream.ReceiveNext();
            _moveDuration = (float)stream.ReceiveNext();
        }
    }
}

EnemyIdle 클래스는 포톤 뷰를 갖고 있기 때문에 자체적으로 OnPhotonSerializeView 함수를 실행하게 되면 에러가 발생할 것이다. 하지만 UnitController에서 List를 통해 변수로써 관리하고 있고 EnemyIdle 클래스에서는 UnitController를 변수로써 갖고 있기 때문에 UnitController 포톤뷰를 통해 해당 함수를 실행할 수 있는 것이다.

구현 후기

이 짤이 내 상황을 완벽히 대변해주는 것 같다

진짜 간단한 캐릭터 움직임, 적 움직임만 구현했는데도 원래 싱글 플레이 코드에서 수정해야 할 부분이 산더미였다. 여기 수정하면 저기서 에러가 생기고 저기 고치면 다시 여기서 에러가 생기고 어찌저찌 해결했나 싶으면 전혀 새로운 곳에서 또 에러가 발생하는 무간지옥이었다.

지금도 글을 쓰면서 FSM 동기화 부분을 수정하는데 에러가 발생해서 다시 고쳐야 할 것 같다... 이번에는 얼마나 걸릴지 모르겠다

어쨌든 정말 어렵고 심오한 네트워킹이지만 색다른 재미가 있는 것 같다. 그리고 지금까지 주먹구구식으로 코딩했다면 네트워크 구현 부분에서는 훨씬 섬세한 코딩이 필요할 것 같고 개념 또한 더 확실히 잡아야 할 것 같다.

'Unity > Devlog' 카테고리의 다른 글

[Unity2D] 1:1 대전 게임  (0) 2023.10.04
[Unity2D] 인벤토리 시스템  (0) 2023.03.24
[Unity 2D] 유닛 확장  (0) 2023.01.23
[Unity 2D] 캐릭터 움직임  (0) 2023.01.23
[Unity 3D] NPC 퀘스트 팝업  (0) 2023.01.16
Comments