내일배움캠프/프로젝트

팀 프로젝트 마무리 정리 - 멀티플레이 구현

서보훈 2025. 1. 15. 21:00

팀 프로젝트 종료 기한이 다가옴에 따라 최종적으로 작성한 코드들을 정리하는 시간을 가져보고자 합니다.

 

게임 기획상 멀티플레이를 구현하게 되었고, 멀트플레이에 사용되는 오브젝트의 클래스들은 NetworkBehavior 를 사용하여 구현하게 되었습니다.


스폰을 위한 클래스 - NetworkSpawnController

멀티플레이를 준비하면서, 멀티플레이에 동기화 되는 오브젝트들은 서버측에서 스폰을 해주어야 사용이 가능합니다.

스폰 기능은 NetworkObject 에 메서드로써 구현되어있으며, NetworkBehavior 를 가진 클래스는 해당 NetworkObject 컴포넌트를 반드시 가지고 있어야합니다.

더보기

스폰 컨트롤러

public class NetworkSpawnController : MonoSingleton<NetworkSpawnController>
{
    public NetworkObject networkGameManager;
    public NetworkObject professorPrefab;
    public NetworkObject studentPrefab;
    public NetworkObject puzzleRepeaterPrefab;

    private NavMeshSurface navMesh;

    protected override void Awake()
    {
        Create();

        if (Instance != this)
        {
            Destroy(gameObject);
        }

        navMesh = GetComponent<NavMeshSurface>();
        NetworkManager.Singleton.SceneManager.OnSynchronizeComplete += SpawnManager;

        if (NetworkManager.Singleton.IsHost)
        {
            NetworkObject puzzle = Instantiate(puzzleRepeaterPrefab);
            puzzle.Spawn(true);
        }
    }

    public void SpawnManager(ulong clientId)
    {
        if (NetworkManager.Singleton.IsHost && NetworkManager.ServerClientId != clientId)
        {
            NetworkObject netGameManager = Instantiate(networkGameManager);
            netGameManager.Spawn(true);
        }
    }

    public void SpawnPlayer(ulong professorId, ulong studentId)
    {
        if (!NetworkManager.Singleton.IsHost) return;

        NetworkObject professor = Instantiate(professorPrefab, GameManager.Instance.mapData.professorSpawnPos.position, Quaternion.identity);
        professor.SpawnAsPlayerObject(professorId, true);

        NetworkObject student = Instantiate(studentPrefab, GameManager.Instance.mapData.studentSpawnPos.position, Quaternion.identity);
        student.SpawnAsPlayerObject(studentId, true);
    }

    public void SpawnGhost()
    {
        navMesh.BuildNavMesh();

        //TODO : 반복문으로 바꿔서 모든 지점에 스폰될수 있도록 변경하기
        for(int i = 0; i < GameManager.Instance.mapData.spawnData.Count; i++)
        {
            Ghost ghost = Instantiate(GameManager.Instance.mapData.spawnData[i].ghostPrefab, GameManager.Instance.mapData.spawnData[0].spawnPosition.position, Quaternion.identity);
            ghost.SetPatrolPoints(GameManager.Instance.mapData.patrolPos[i].patrolPositions);
        }

        SpawnItems();

        GameManager.Instance.networkGameManager.SpawnCompleteServerRpc();
    }

    //무작위 아이템 스폰
    private void SpawnItems()
    {
        List<int> spawnIndex = new List<int>(50);
        for(int i = 0; i < GameManager.Instance.mapData.itemSpawnPositionCount; i++)
        {
            int index = Random.Range(0, i + 1);
            spawnIndex.Insert(index, i);
        }

        int maxIndex = DataManager.Instance.GetKeyCount(EDataType.ItemData);

        for (int i = 0; i < GameManager.Instance.mapData.itemSpawnCount; i++)
        {
            int randomIndex = Random.Range(0, maxIndex);
            Transform target = GameManager.Instance.mapData.studentItemSpawnPos[spawnIndex[i]];
            try
            {
                Instantiate(DataManager.Instance.GetDataByInt<ItemData>(randomIndex).ItemPrefab, target.position, Quaternion.identity);
            }
            catch
            {
                Debug.Log("생성중 문제 발생");
            }
        }
    }
}

메서드는 4종류가 있으며, 각각 서로간 Rpc 통신을 가능하게 하는 NetworkGameManager 의 생성, 두 플레이어의 캐릭터 생성, 학생측 플레이어 씬에 유령 생성, 학생측 플레이어 씬에 아이템을 생성하는 메서드들 입니다.

 

그와 별개로 Awake 문에서 작동하는 코드가 있는데, 일단 매니저의 스폰을 NetworkSceneManager 의 OnSynchronizeComplete 이벤트에 구독시킵니다.

해당 이벤트는 두 플레이어의 씬이 동기화되었을때 작동하는 이벤트로, 씬 로드가 완료되면 NetworkGameManager를 스폰시키면서 게임을 시작할 준비를 하게됩니다.

 

그리고 Awake 문에서 퍼즐관련 Rpc를 전담할 NetworkPuzzleRepeater 를 생성합니다.

※ 이 글을 작성할때 보니 이 내용은 매니저를 스폰할때 같이 처리해주었어야 했던것 같습니다.

 

NetworkSapwnManager의 경우, 꼭 NetworkObject 가 아니더라도 양 플레이어의 씬이 동기화 된 후 생성해야하는 오브젝트들을 생성하는 역할을 합니다.

 


멀티플레이 진행용 클래스 - NetworkGameManager

NetworkGameManager의 경우, 게임의 진행이나 종료등에 필요한 Rpc를 처리하기위해 작성한 클래스입니다.

게임을 시작할 때 역할을 분배하는 작업,

게임 오버를 처리할 때, 한쪽에서 발생한 게임오버 트리거를 멀티플레이 참여자에게 전달하는 역할을 합니다.

더보기
public class NetworkGameManager : NetworkBehaviour
{
    private NetworkManager networkManager;
    private UnityTransport transport;

    private int isHostProfessor = -1;

    public ulong ProfessorId { get; private set; }
    public ulong StudentId { get; private set; }

    public bool HostReadyFlag { get; private set; }
    public bool ClientReadyFlag { get; private set; }

    //생성 완료 플래그
    private bool isRoleDicided = false;
    private bool isMapCreated = false;

    public override void OnNetworkSpawn()
    {
        GameManager.Instance.networkGameManager = this;

        networkManager = NetworkManager.Singleton;
        transport = networkManager.GetComponent<UnityTransport>();

        DoNextStep();
    }

    private void RoleDicider()
    {
        if (!IsHost) return;

        ulong clientId = 0;

        for(int i = 0; i < networkManager.ConnectedClientsList.Count; i++)
        {
            if (networkManager.ConnectedClientsIds[i] == transport.ServerClientId) continue;

            clientId = networkManager.ConnectedClientsIds[i];
        }

        int random = Random.Range(0, 2);

        if(random == 0)
        {
            isHostProfessor = 0;
            ProfessorId = transport.ServerClientId;
            StudentId = clientId;
        }
        else
        {
            isHostProfessor = 1;
            ProfessorId = clientId;
            StudentId = transport.ServerClientId;
        }

        SetPlayerClientRpc(isHostProfessor);
        isRoleDicided = true;

        DoNextStep();
    }

    [ClientRpc]
    private void SetPlayerClientRpc(int index)
    {
        isHostProfessor = index;

        SetPlayerRole();
    }

    [ServerRpc(RequireOwnership = false)]
    public void SpawnCompleteServerRpc()
    {
        CloseLoadingPanelClientRpc();
    }

    [ClientRpc]
    public void CloseLoadingPanelClientRpc()
    {
        LoadingSceneController.Instance.CloseGameLoadingPanel();

        Cursor.lockState = CursorLockMode.None;
        Cursor.visible = true;

        if(NetworkManager.Singleton.LocalClientId == StudentId)
        {
            UIManager.Instance.CreateUI<PostGraduateGuideUI>();
        }
        else
        {
            UIManager.Instance.CreateUI<ProfessorGuideUI>();
        }
    }

    private void SetPlayerRole()
    {
        if (networkManager.IsHost) return;

        if(isHostProfessor == 0)
        {
            ProfessorId = transport.ServerClientId;
            StudentId = networkManager.LocalClientId;
        }
        else if (isHostProfessor == 1)
        {
            ProfessorId = networkManager.LocalClientId;
            StudentId = transport.ServerClientId;
        }
        else
        {
            Debug.Log("실패");
        }

        isRoleDicided = true;
    }

    private void MapSelector()
    {
        if(!IsHost) return;
        //TODO : 차후에 맵을 무작위로 선택하게 만들기
        MapListSO mapList = ResourceManager.Instance.LoadAsset<MapListSO>("MapDataList", eAssetType.Data, eCategoryType.SO);

        GameManager.Instance.mapData = mapList.list[0];

        Debug.Log(GameManager.Instance.mapData);

        CreateMapClientRpc(0);

        CreateMap();

        HostReadyFlag = true;

        ReadyComplete();
    }

    [ClientRpc]
    private void CreateMapClientRpc(int index)
    {
        if (IsHost) return;
        MapListSO mapList = ResourceManager.Instance.LoadAsset<MapListSO>("MapDataList", eAssetType.Data, eCategoryType.SO);
        GameManager.Instance.mapData = mapList.list[index];

        CreateMap();

        ClientReadyServerRpc();
    }

    private void CreateMap()
    {
        Instantiate(GameManager.Instance.mapData, Vector3.zero, Quaternion.identity);
        isMapCreated = true;
    }

    [ServerRpc(RequireOwnership = false)]
    private void ClientReadyServerRpc()
    {
        if(!IsHost) return;
        ClientReadyFlag = true;

        ReadyComplete();
    }

    private void ReadyComplete()
    {
        if(ClientReadyFlag && HostReadyFlag)
        {
            ClientReadyFlag = false;
            HostReadyFlag = false;

            DoNextStep();
        }
    }

    private void DoNextStep()
    {
        if (!IsHost) return;

        if (!isRoleDicided)
        {
            RoleDicider();
        }
        else if (!isMapCreated)
        {
            MapSelector();
        }
        else
        {
            NetworkSpawnController.Instance.SpawnPlayer(ProfessorId, StudentId);
            SpawnGhostClientRpc();
        }
    }

    [ClientRpc]
    private void SpawnGhostClientRpc()
    {
        if (NetworkManager.Singleton.LocalClientId == StudentId)
        {
            NetworkSpawnController.Instance.SpawnGhost();
        }
    }

    [ServerRpc(RequireOwnership = false)]
    public void SendGameResultServerRpc(int enumIndex)
    {
        ReciveGameResultClientRpc(enumIndex);
    }

    [ClientRpc]
    private void ReciveGameResultClientRpc(int enumIndex)
    {
        ResultType type = (ResultType)enumIndex;
        GameManager.Instance.Gameover(type);
    }
}

해당 클래스는 스폰 될 때, 호스트일 경우 역할을 분배, 맵 선택, 플레이어 와 유령 생성 을 순차적으로 작동합니다.

클라이언트에서 작업이 완료되면 ReadyComplete 메서드를 호출하며, 호스트와 클라이언트 모두 준비가 완료되면 다음 작업을 시작하는 방식으로 만들어주었습니다.

(상당히 비효율적이라고 생각합니다...)

 

또한 주요 역할으로 게임 결과 트리거가 작동되면 게임 결과를 서버로 보내고, 서버는 다시 클라이언트로 게임 결과를 보내어 게임을 종료할 수 있게 하는 역할을 합니다.

 

또한 씬이 넘어갈 때, 로딩패널을 켜는데, 게임 준비가 모두 완료되면 양측 플레이어의 로딩패널을 끄는 역할을 합니다.

 


연결 관리 클래스 - NetworkConnectController

해당 클래스는 서로간의 멀티플레이 연결을 관리하는 클래스 입니다.

게임 종료시 멀티플레이 연결을 끊는 역할과, 연결이 비정상적으로 끊어질경우 로비 씬으로 나갈수 있게 하는 역할을 합니다.

더보기
public class NetworkConnectController : MonoSingleton<NetworkConnectController>
{
    //게스트 플레이어의 경우, 서버와 연결이 끊어졌을때 이벤트를 받아서 연결 종료
    private void Start()
    {
        NetworkManager.Singleton.OnClientDisconnectCallback += ClientDisconnetCallback;
    }

    //서버를 종료시 해야하는 작업들
    public void DisconnetServer()
    {
        NetworkManager.Singleton.Shutdown(true);
        StartCoroutine(WaitingForShutdown());
    }

    //비복스 채널 나가기
    public void ExitVivoxChennel()
    {
        VivoxController.Instance.LeaveVoiceChannel();
        VoiceChatSetter.Instance.ClaerChatList();
    }
    
    //호스트가 서버를 종료하는 메서드
    public void ShutDownHost()
    {
        if (!NetworkManager.Singleton.IsHost) return;
        DisconnetAllClient();

        DisconnetServer();
    }

    //클라이언트 연결 해제
    public void DisconnetAllClient()
    {
        for(int i = 0; i < NetworkManager.Singleton.ConnectedClientsList.Count; i++)
        {
            ulong id = NetworkManager.Singleton.ConnectedClientsIds[i];
            if (id == NetworkManager.Singleton.GetComponent<UnityTransport>().ServerClientId) continue;

            NetworkManager.Singleton.DisconnectClient(id);
        }
    }

    //연결이 끊겼을때 호출되는 메서드
    public void ClientDisconnetCallback(ulong id)
    {
        if (GameManager.Instance.IsGameover == false)
        {
            LobbyManager.Instance.IsPlaying = false;
            LobbyManager.Instance.LeaveLobby();
            GameManager.Instance.DisconnectError();
        }

        LobbyManager.Instance.BackToLobby();
        DisconnetServer();
    }

    private IEnumerator WaitingForShutdown()
    {
        while(NetworkManager.Singleton.ShutdownInProgress)
        {
            yield return null;
        }

        LobbyManager.Instance.IsPlaying = false;
        GameManager.Instance.ActiveExitButton();
    }
}

씬 매니저와 다시 플레이를 시작할 때 문제를 경험한 후, 서버 종료시 NetworkManager 의 ShutdownInProgress 값이 true 일때는 서버 연결을 끊는 작업중이며, 반환값이 false 가 되어야 완전히 연결이 끊어진다는것을 알게 되어 호스트가 서버를 종료할경우에 연결이 끊어질때 까지 대기하는 코루틴을 추가하였습니다.

 

또한 해당 클래스에서 Vivox의 연결을 끊는 작업도 관리하고 있습니다.

 

그 외에도 클라이언트의 연결이 끊겼을 때 게임 종료로 인한 연결 끊김인지 비정상적인 연결 끊김인지를 관리하여 게임 결과 UI를 보여주는 역할을 합니다.


퍼즐 관련 Rpc 관리 클래스 - NetworkPuzzleRepeater

퍼즐관련 통신을 담당하는 클래스 입니다.

퍼즐 시작 트리거가 발생하면 퍼즐의 정답을 생성하는데, 이 정답을 다른 플레이어에게 전달하는 역할과

퍼즐의 성공, 실패시 그 결과를 전달해주는 역할을 합니다.

더보기
public class NetworkPuzzleRepeater : NetworkBehaviour
{
    private int curPuzzleIndex;

    public override void OnNetworkSpawn()
    {
        GameManager.Instance.repeater = this;
    }

    [ServerRpc(RequireOwnership = false)]
    public void SynchronizePuzzleServerRpc(int[] hintNumbers, int puzzleIndex)
    {
        SynchronizePuzzleClientRpc(hintNumbers, puzzleIndex);
    }

    [ClientRpc]
    public void SynchronizePuzzleClientRpc(int[] numbers, int puzzleIndex)
    {
        //TODO : 게임 매니저 싱글톤에 Puzzle 올려서 퍼즐에 이 내용 넣어주고 작동시키기
        PuzzleManager.Instance.subPuzzleList[puzzleIndex].SetPuzzleByNetwork(numbers);
        curPuzzleIndex = puzzleIndex;
    }

    [ServerRpc(RequireOwnership = false)]
    public void PuzzleClearServerRpc(int puzzleIndex)
    {
        PuzzleClearClientRpc(puzzleIndex);
    }

    [ClientRpc]
    public void PuzzleClearClientRpc(int puzzleIndex)
    {
        PuzzleManager.Instance.subPuzzleList[puzzleIndex].SetClearByNetwork();
    }

    [ServerRpc(RequireOwnership = false)]
    public void PuzzleStartRequestServerRpc()
    {
        StartPuzzleClientRpc();
    }

    [ClientRpc]
    public void StartPuzzleClientRpc()
    {
        if (GameManager.Instance.networkGameManager.ProfessorId == NetworkManager.Singleton.LocalClientId) return;
        PuzzleManager.Instance.EnterPuzzlePoint();
    }

    [ServerRpc(RequireOwnership = false)]
    public void PuzzleFailServerRpc()
    {
        PuzzleFailClientRpc();
    }

    [ClientRpc]
    public void PuzzleFailClientRpc()
    {
        if (GameManager.Instance.networkGameManager.StudentId == NetworkManager.Singleton.LocalClientId) return;
        PuzzleManager.Instance.subPuzzleList[curPuzzleIndex].FailPuzzleByNetwork();
    }

}

퍼즐들은 기반이 되는 Puzzle 클래스가 있고, 해당 클래스에 네트워크 관련 메서드를 구현한 뒤 override를 하는 방식으로 다른 정보를 관리하는 퍼즐에 접근 할 수 있도록 관리하고 있습니다.

 


플레이어 컴포넌트 관리 - PlayerNetwork

두명의 플레이어는 각자 카메라 위치와, 조작 관련 클래스를 가지고 있습니다.

이러한 컴포넌트들을 캐릭터 소유권에 따라 활성화/비활성화 해주는 클래스입니다.

더보기
public class PlayerNetwork : NetworkBehaviour
{
    //TODO : 플레이어 컴포넌트 전부 다 가져오기
    private CharacterController charController;
    private BasePlayer playerBase;
    private PlayerController controller;
    [SerializeField]
    private GameObject camContainer;
    private InteractionPoint interaction;
    private PlayerEquipment equipment;
    private Inventory inven;

    //스폰할때, 자신 소유가 아니면 전부 비활성화
    public override void OnNetworkSpawn()
    {
        charController = GetComponent<CharacterController>();
        playerBase = GetComponent<BasePlayer>();
        controller = GetComponent<PlayerController>();
        interaction = GetComponent<InteractionPoint>();
        equipment = GetComponent<PlayerEquipment>();
        inven = GetComponent<Inventory>();

        if(OwnerClientId == GameManager.Instance.networkGameManager.StudentId)
        {
            GameManager.Instance.studentPlayerObject = gameObject;
        }

        if(!IsOwner)
        {
            playerBase.audioSource.enabled = false;
            charController.enabled = false;
            playerBase.enabled = false;
            controller.enabled = false;
            interaction.enabled = false;
            equipment.enabled = false;
            inven.enabled = false;

            camContainer.SetActive(false);
        }
        else
        {
            playerBase.audioSource.enabled = true;
            charController.enabled = true;
            playerBase.enabled = true;
            controller.enabled = true;
            interaction.enabled = true;
            equipment.enabled = true;
            inven.enabled = true;

            GameManager.Instance.contolledPlayer = playerBase;

            camContainer.SetActive(true);
        }
    }
}

역할은 단순히 스폰된 후, 소유권 여부에 따라 컴포넌트를 활성화/비활성화 하는 역할만을 담당하고 있습니다.

 

최초로 카메라 사용시 2개의 오디오 리스너 문제, 카메라 문제를 해결하기 위해 만들어주었으며, 이후 CharacterController 로 인한 위치문제 등을 해결하기 위해 만들어주었습니다.

 

NetworkBehaviour 의 경우 Awake 대신 OnNetworkSpawn() 메서드를 오버라이드 해서 사용해야한다는것을 알게 된 클래스이기도 합니다.

 


씬 관리용 클래스 NetworkSceneChanger

두 플레이어의 씬 변경을 관리하기위해 만들어준 클래스입니다.

SceneManager 와 NetworkSceneManager를 모두 사용합니다.

더보기
public class NetworkSceneChanger : MonoSingleton<NetworkSceneChanger>
{
    private Scene currentScene;
    private Scene preScene;
    public string VoiceChannelName { get; set; }

    private void Start()
    {
        currentScene = SceneManager.GetActiveScene();
        SceneManager.LoadScene("LoadingScene", LoadSceneMode.Additive);

        //씬 로드후, ActiveScene 내용을 바꿔주기 위해 사용
        SceneManager.sceneLoaded += OnSceneLoad;
    }

    //게임 시작
    public void ChangeScene(string sceneName)
    {
        SoundManager.Instance.StopBGM();

        //이전의 Active씬 언로드
        SceneManager.UnloadSceneAsync(currentScene);

        if (NetworkManager.Singleton.IsHost)
        {
            //호스트가 씬 변경후, 클라이언트와 씬을 동기화
            NetworkManager.Singleton.SceneManager.ActiveSceneSynchronizationEnabled = true;
            NetworkManager.Singleton.SceneManager.SetClientSynchronizationMode(LoadSceneMode.Additive);
            NetworkManager.Singleton.SceneManager.LoadScene(sceneName, LoadSceneMode.Additive);
        }
    }

    private void OnSceneLoad(Scene scene, LoadSceneMode mode)
    {
        //로딩씬이 로드될때는 작동하지 않음
        if (scene.name == "LoadingScene") return;

        //ActiveScene 변경
        SceneManager.SetActiveScene(scene);
        preScene = currentScene;
        currentScene = scene;

        if(currentScene.name == "LobbyScene")
        {
            LobbyManager.Instance.CreateLobbyUI();
            LobbyManager.Instance.IsPlaying = false;
        }
    }

    public void ClientChangeScene(string sceneName)
    {
        //연결 해제등의 이유로 서버를 통해서가 아닌 클라이언트가 씬을 변경할때 사용
        SceneManager.UnloadSceneAsync(currentScene);

        SceneManager.LoadScene(sceneName, LoadSceneMode.Additive);
    }
}

 

게임을 시작할때, SceneManager를 통해 기존 로비씬을 언로드하고, NetworkSceneManager를 통해 플레이씬으로 넘어가며, 게임을 종료할 때는 SceneManager를 통해 플레이 씬을 언로드하고, 로비씬을 로드하게 됩니다.

 

해당 내용의경우, NetworkSceneManager가 씬을 언로드 할 경우, NetworkSceneManager로 로드된 씬만 언로드 할 수 있음을 알게되면서 이러한 구조를 취하게 되었습니다.

 

또한 이 클래스에서 로딩씬을 로드하고 있습니다.

 


현재 네트워크 연결과 관련된 문제가 만들어지고 있는만큼, 문제가 발생되는것으로 의심되는부분, 게임 로딩중일때의 예외처리가 추가적으로 필요함을 느끼고 있습니다.

 

추가 작업으로 문제 발생시 발생하는 UI를 제작하고, 해당 UI를 통해 어떤 문제가 발생했는지 볼 수 있도록 작업을 진행할 예정입니다.