TIL

[멋쟁이사자처럼 부트캠프 TIL회고] Unity 게임개발 0307 : 채팅, 멀티플레이

Cadi 2025. 3. 7. 22:11

 

 

오늘 과제

1. 채팅 구현

2. 멀티 플레이 구현

 

01. 채팅 구현

Dynamic string에 있는 것으로 해야 한다. 

 

 

 

웹소켓(SocketIO)를 사용할 것이다. 

 

  //이렇게 람다로 만들어도 되고, 새로운 함수(밑)으로 만들어도 된다. 
    _socket.On("createRoom", (data) =>
    {
        
    });

   
}
// 항상 매개변수는 있어야 한다. 받을지 말지 모르기 떄문에
private void CreateRoom(SocketIOResponse response)
{
        
}

 

 

* ParrelSCyn 이용시 주의점 : 변경사항들을 공유한다,  따로 오가며 수정해줄 필요는 없지만 가끔 클릭 등으로 동기화 해 주어야 한다. 

 

첫 번째 MultiplayerManager클래스이다. 

SocketIO를 이용해서 통신하고, Action을 통해 받아온 함수들을 실행하는 역할을 할 것이다. 

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using SocketIOClient;
using Newtonsoft.Json;

public class RoomData
{
    [JsonProperty("roomId")] public string roomId { get; set; }
}

public class UserData
{
    [JsonProperty("userId")] public string userId { get; set; }
}

public class MessageData
{
    [JsonProperty("nickName")] public string nickName { get; set; }
    [JsonProperty("message")] public string message { get; set; }
}

public class MultiplayManager : IDisposable
{
    private SocketIOUnity _socket;

    //상태를 전달해줘야 할 것 rkxdma
    public event Action<Constants.MultiplayManagerState, string> _onMultiplayChanged;

    // 채팅을 붙였다 뗏다 하기 위한 목적을 분리를 해 놨음(할당했다가 분리했다가 할 용도)
    public Action<MessageData> OnReceivedMessage;

    // 생성자, Action을 받아서 등록해놓는. 
    public MultiplayManager(Action<Constants.MultiplayManagerState, string> onMultiplayChanged)
    {
        var uri = new Uri(Constants.GameServerURL);
        _onMultiplayChanged = onMultiplayChanged;

        _socket = new SocketIOUnity(uri, new SocketIOOptions()
        {
            Transport = SocketIOClient.Transport.TransportProtocol.WebSocket
        });

        //람다로 만들어도 되고, 새로운 함수(밑)으로 만들어도 된다. 
        _socket.On("createRoom", CreateRoom);
        _socket.On("joinRoom", JoinRoom);
        _socket.On("startGame", StartGame);
        _socket.On("gameEnded", GameEnded);
        _socket.On("receiveMessage", ReceivedMessage);

        //연결됨
        _socket.Connect();
    }

    // 항상 매개변수는 있어야 한다. 받을지 말지 모르기 떄문에
    private void CreateRoom(SocketIOResponse response)
    {
        var data = response.GetValue<RoomData>();
        _onMultiplayChanged?.Invoke(Constants.MultiplayManagerState.CreateRoom, data.roomId);
    }

    private void JoinRoom(SocketIOResponse response)
    {
        var data = response.GetValue<RoomData>();
        UnityThread.executeInUpdate(() =>
            _onMultiplayChanged?.Invoke(Constants.MultiplayManagerState.JoinRoom, data.roomId));
    }

    private void StartGame(SocketIOResponse response)
    {
        var data = response.GetValue<UserData>();
        UnityThread.executeInUpdate(() =>
            _onMultiplayChanged?.Invoke(Constants.MultiplayManagerState.StartGame, data.userId));
    }

    private void GameEnded(SocketIOResponse response)
    {
        var data = response.GetValue<UserData>();

        _onMultiplayChanged?.Invoke(Constants.MultiplayManagerState.EndGame, data.userId);
    }

    private void ReceivedMessage(SocketIOResponse response)
    {
        Debug.Log("메세지 받음");
        var data = response.GetValue<MessageData>();
        OnReceivedMessage?.Invoke(data);
    }

    public void SendMessage(string roomId, string nickName, string message)
    {
        _socket.Emit("sendMessage", new { roomId, nickName, message });
    }

    public void Dispose()
    {
        if (_socket != null)
        {
            _socket.Disconnect();
            _socket.Dispose();
            _socket = null;
        }
    }
}

 

 

우선 변수들을 정의해 준다. 

private SocketIOUnity _socket;

//상태를 전달해줘야 할 것 rkxdma
public event Action<Constants.MultiplayManagerState, string> _onMultiplayChanged;

// 채팅을 붙였다 뗏다 하기 위한 목적을 분리를 해 놨음(할당했다가 분리했다가 할 용도)
public Action<MessageData> OnReceivedMessage;

Socket 변수, 전달받아 실행할 Action _onMultiplayChanged (상태가 변했다는 메세지 받으면 실행), 

그리고 채팅을 받을 수 있게 만들어주는 OnReceivedMessage 함수이다. 

이것을 따로 분리한 이유는, OnReceivedMessage 대리자를 할당했다 분리했다 하면서 채팅을 받을 수 있는 상태와

그렇지 않은 상태를 구분하기 위해서이다. 

 

 

다음은 생성자 파트이다. 

// 생성자, Action을 받아서 등록해놓는. 
public MultiplayManager(Action<Constants.MultiplayManagerState, string> onMultiplayChanged)
{
    var uri = new Uri(Constants.GameServerURL);
    _onMultiplayChanged = onMultiplayChanged;

    _socket = new SocketIOUnity(uri, new SocketIOOptions()
    {
        Transport = SocketIOClient.Transport.TransportProtocol.WebSocket
    });

    //람다로 만들어도 되고, 새로운 함수(밑)으로 만들어도 된다. 
    _socket.On("createRoom", CreateRoom);
    _socket.On("joinRoom", JoinRoom);
    _socket.On("startGame", StartGame);
    _socket.On("gameEnded", GameEnded);
    _socket.On("receiveMessage", ReceivedMessage);

    //연결됨
    _socket.Connect();
}

 

생성할 때 상태에 따라 다르게 실행되는 변수(함수)를 매개변수로 받고 설정한다.

필요한 uri를 받고 설정한다.

소켓 또한 새로운 소켓을 만들어 설정한다.

 

그리고 만든 소켓에 특정 신호를 받으면 다음 함수들이 실행되게 한 후 연결한다. 

private void JoinRoom(SocketIOResponse response)
{
    var data = response.GetValue<RoomData>();
    UnityThread.executeInUpdate(() =>
        _onMultiplayChanged?.Invoke(Constants.MultiplayManagerState.JoinRoom, data.roomId));
}

private void StartGame(SocketIOResponse response)
{
    var data = response.GetValue<UserData>();
    UnityThread.executeInUpdate(() =>
        _onMultiplayChanged?.Invoke(Constants.MultiplayManagerState.StartGame, data.userId));
}

private void GameEnded(SocketIOResponse response)
{
    var data = response.GetValue<UserData>();

    _onMultiplayChanged?.Invoke(Constants.MultiplayManagerState.EndGame, data.userId);
}

private void ReceivedMessage(SocketIOResponse response)
{
    Debug.Log("메세지 받음");
    var data = response.GetValue<MessageData>();
    OnReceivedMessage?.Invoke(data);
}

public void SendMessage(string roomId, string nickName, string message)
{
    _socket.Emit("sendMessage", new { roomId, nickName, message });
}

 

각각 매개변수로 response를 받고, 그 response를 바탕으로 등록해둔 Action을 실행한다.

추후에 보면 알겠지만 상태를 넣어주는데, 이 상태에 따라 다른 기능들이 실행된다. 

 

 

 

 

추가 개념 공부 1 : GetValue<T>();

 

 SocketIOResponse.GetValue<T>() 메서드는 Socket.IO 클라이언트 라이브러리에서 제공하는 기능,

JSON 데이터를 C#의 타입 (T) 객체로 변환(역직렬화) 하는 역할을 함.

 

 

 

 

다음은 받은 데이터를 갖고 실행하는 부분이다. 

using System.Collections;
using System.Collections.Generic;
using TMPro;
using UnityEditor.VersionControl;
using UnityEngine;
using UnityEngine.Serialization;
using UnityEngine.UI;

public class ChattingPanelController : MonoBehaviour
{
 [SerializeField] private TMP_InputField messageInputField;
[SerializeField] private GameObject messagePrefab;
[SerializeField] private Transform messageTextParent;

private MultiplayManager multiplayerManager;

private string _roomId;

public void OnEndEidtInputField(string messageText)
{
    var messageTextObject = Instantiate(messagePrefab, messageTextParent);
    messageTextObject.GetComponent<TMP_Text>().text = messageText;
    messageInputField.text = "";

     Debug.Log(_roomId + ": " + multiplayerManager);
    if (_roomId != null && multiplayerManager != null)
    {
        multiplayerManager.SendMessage(_roomId, "Cadi", messageText);
    }
    
}


void Start()
{
    messageInputField.interactable = false;
    
    multiplayerManager = new MultiplayManager((state, id) =>
    {
     switch(state)
     {
         case Constants.MultiplayManagerState.CreateRoom:
             _roomId = id;
             Debug.Log("## Create Room ##");
             break;
         case Constants.MultiplayManagerState.JoinRoom:
              messageInputField.interactable = true;

             Debug.Log("## Joining Room ##");
             _roomId = id;
             Debug.Log(_roomId);
             break;
         case Constants.MultiplayManagerState.StartGame:
             messageInputField.interactable = true;

             Debug.Log("## Start Game ##");
             break;
         case Constants.MultiplayManagerState.EndGame:
             Debug.Log("## End Game ##");
             break;
         
     }   
    });

    multiplayerManager.OnReceivedMessage = OnReceiveMessage;
}

// 이 메서드 같은 경우 메인스레드에서 동작하게 바꿔주어야 함
    private void OnReceiveMessage(MessageData messageData)
    {

        UnityThread.executeInUpdate(() =>
        {
            var messageTextObject = Instantiate(messagePrefab, messageTextParent);
            messageTextObject.GetComponent<TMP_Text>().text = messageData.nickName + " : " + messageData.message;

        });
    }

    private void OnApplicationQuit(){
        multiplayerManager.Dispose();
    }
}

 

public void OnEndEidtInputField(string messageText)
{
    var messageTextObject = Instantiate(messagePrefab, messageTextParent);
    messageTextObject.GetComponent<TMP_Text>().text = messageText;
    messageInputField.text = "";

     Debug.Log(_roomId + ": " + multiplayerManager);
    if (_roomId != null && multiplayerManager != null)
    {
        multiplayerManager.SendMessage(_roomId, "Cadi", messageText);
    }
    
}

 

이 부분은 InputField의 OnEditEnd에 물려 있어 엔터를 치면 실행된다.

중요한 부분은 SendMessage하는 부분이다. 설정된 roomID로 메세지를 보낸다. 

 

룸 아이디와 다른 것들은 밑의 코드에서 설정하게 된다. 

void Start()
{
    messageInputField.interactable = false;
    
    multiplayerManager = new MultiplayManager((state, id) =>
    {
     switch(state)
     {
         case Constants.MultiplayManagerState.CreateRoom:
             _roomId = id;
             Debug.Log("## Create Room ##");
             break;
         case Constants.MultiplayManagerState.JoinRoom:
              messageInputField.interactable = true;

             Debug.Log("## Joining Room ##");
             _roomId = id;
             Debug.Log(_roomId);
             break;
         case Constants.MultiplayManagerState.StartGame:
             messageInputField.interactable = true;

             Debug.Log("## Start Game ##");
             break;
         case Constants.MultiplayManagerState.EndGame:
             Debug.Log("## End Game ##");
             break;
         
     }   
    });

    multiplayerManager.OnReceivedMessage = OnReceiveMessage;
}

채팅 패널에 멀티플레이 매니저 객체가 컴포넌트로 있게 되고, 상태에 따라 다른 행동을 하게 된다.

(상태가 변하면, 즉 신호를 받으면 Inovoke 되는 것을 봤음)

 

 

보내는 것 뿐만 아니라 받는 것도 있어야 한다.

private void OnReceiveMessage(MessageData messageData)
{

    UnityThread.executeInUpdate(() =>
    {
        var messageTextObject = Instantiate(messagePrefab, messageTextParent);
        messageTextObject.GetComponent<TMP_Text>().text = messageData.nickName + " : " + messageData.message;

    });
}

private void OnApplicationQuit(){
    multiplayerManager.Dispose();
}

 

 

그리고 끝났을 때는 자연스럽게 Dispose() 해 주면 된다. 

 

 

 

 

 

헤맸던 부분 : 서브스레드에서 UI 요소 동작 불가

 

private void JoinRoom(SocketIOResponse response)
{
    var data = response.GetValue<RoomData>();
    UnityThread.executeInUpdate(() =>
        _onMultiplayChanged?.Invoke(Constants.MultiplayManagerState.JoinRoom, data.roomId));
}

private void StartGame(SocketIOResponse response)
{
    var data = response.GetValue<UserData>();
    UnityThread.executeInUpdate(() =>
        _onMultiplayChanged?.Invoke(Constants.MultiplayManagerState.StartGame, data.userId));
}

 

요 부분이다, 원래는 UnityThread.executeInUpdat()로 묶어주지 않아 그냥 실행되었는데, 

On으로 받아온 신호는 서브스레드에서 실행되기에 유니티의 UI에 직접적인 영향을 가하려고 하면 오류가 나게 된다.

오류가 나서 그 밑의 코드가 실행되지 않았고, 따라서 roomId가 제대로 설정되지 않아, 처음에 룸을 만든 클라이언트는 정상 작동했지만 이후 join으로 참여한 클라이언트는 정상적으로 메세지를 보낼 수 없는(roomId가 null이므로) 문제가 발생했다. 한 두 시간 걸려서 찾았는데 다른 분들도 똑같은 문제가 있었고 이미 해결된 문제였다.

 

그래도 앞으로는 '감히' 서브스레드에서 UI를 건드는 짓을 안하게 된 것 같으니 한 잔 해 ~ 

 

 

02. 멀티 플레이

일단 코드를 보자면 

 

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using SocketIOClient;
using Newtonsoft.Json;

public class RoomData
{
    [JsonProperty("roomId")] public string roomId { get; set; }
}

public class UserData
{
    [JsonProperty("userId")] public string userId { get; set; }
}

public class MessageData
{
    [JsonProperty("nickName")] public string nickName { get; set; }
    [JsonProperty("message")] public string message { get; set; }
}

public class BlockData
{
    public int turn { get; set; }
    public int x { get; set; }
    public int y { get; set; }
}

public class MultiplayManager : IDisposable
{
    private SocketIOUnity _socket;

    //상태를 전달해줘야 할 것 rkxdma
    public event Action<Constants.MultiplayManagerState, string> _onMultiplayChanged;

    // 채팅을 붙였다 뗏다 하기 위한 목적을 분리를 해 놨음(할당했다가 분리했다가 할 용도)
    public Action<MessageData> OnReceivedMessage;

    // 생성자, Action을 받아서 등록해놓는. 
    public MultiplayManager(Action<Constants.MultiplayManagerState, string> onMultiplayChanged)
    {
        var uri = new Uri(Constants.GameServerURL);
        _onMultiplayChanged = onMultiplayChanged;

        _socket = new SocketIOUnity(uri, new SocketIOOptions()
        {
            Transport = SocketIOClient.Transport.TransportProtocol.WebSocket
        });

        //람다로 만들어도 되고, 새로운 함수(밑)으로 만들어도 된다. 
        _socket.On("createRoom", CreateRoom);
        _socket.On("joinRoom", JoinRoom);
        _socket.On("startGame", StartGame);
        _socket.On("gameEnded", GameEnded);
        _socket.On("receiveMessage", ReceivedMessage);
        _socket.On("blockChanged", BlockChanged);

        //연결됨
        _socket.Connect();
    }

    // 항상 매개변수는 있어야 한다. 받을지 말지 모르기 떄문에
    private void CreateRoom(SocketIOResponse response)
    {
        var data = response.GetValue<RoomData>();
        _onMultiplayChanged?.Invoke(Constants.MultiplayManagerState.CreateRoom, data.roomId);
    }

    private void JoinRoom(SocketIOResponse response)
    {
        var data = response.GetValue<RoomData>();
        UnityThread.executeInUpdate(() =>
            _onMultiplayChanged?.Invoke(Constants.MultiplayManagerState.JoinRoom, data.roomId));
    }

    private void StartGame(SocketIOResponse response)
    {
        var data = response.GetValue<UserData>();
        UnityThread.executeInUpdate(() =>
            _onMultiplayChanged?.Invoke(Constants.MultiplayManagerState.StartGame, data.userId));
    }

    private void GameEnded(SocketIOResponse response)
    {
        var data = response.GetValue<UserData>();

        _onMultiplayChanged?.Invoke(Constants.MultiplayManagerState.EndGame, data.userId);
    }

    private void ReceivedMessage(SocketIOResponse response)
    {
        var data = response.GetValue<MessageData>();
        OnReceivedMessage?.Invoke(data);
    }

    public void SendMessage(string roomId, string nickName, string message)
    {
        _socket.Emit("sendMessage", new { roomId, nickName, message });
    }

    private void BlockChanged(SocketIOResponse response)
    {
        var data = response.GetValue<BlockData>();
        Debug.Log(data);
        string data1 = data.turn.ToString();
        data1 += data.x.ToString();
        data1 += data.y.ToString();
        Debug.Log($"data1 = {data1}");
        _onMultiplayChanged.Invoke(Constants.MultiplayManagerState.BlockChanged, data1);
    }

    public void ChangeBlock(string roomId, GameManager.TurnType turnType, int x, int y)
    {
        Debug.Log($"ChangeBlock으로 들어온 x, x : {x}, y : {y}");
        int turn = (turnType == GameManager.TurnType.PlayerB) ? 1 : 0;
        var data = new Dictionary<string, object>
        {
            { "roomId", roomId },
            { "turn", turn },
            { "x", x },
            { "y", y }
        };
        Debug.Log($"data = {JsonConvert.SerializeObject(data)}");
        _socket.Emit("changeBlock", data);
    }


    public void Dispose()
    {
        if (_socket != null)
        {
            _socket.Disconnect();
            _socket.Dispose();
            _socket = null;
        }
    }
}

 

새로운 코드를 만들어도 괜찮지만(오히려 그쪽이 더 깔끔하겠지만) 이용하고 싶어서 우겨넣었다. 

그래서 데이터를 사용할 때 string값으로 변환하고 사용하는 문제가.. 있긴 하다. 

 

private void StartGame()
{
    // _board 초기화
    _board = new PlayerType[3, 3];

    // 블록 초기화
    _blockController.InitBlocks();

    // Game UI 초기화
    _gameUIController.SetGameUIMode(GameUIController.GameUIMode.Init);

    //듀얼모드라면 새롭게 듀얼플레이매니저를 만든다. 
    if (_gameType == GameType.DualPlayer)
    {
        _multiplayManager = new MultiplayManager((state, id) =>
        {
            switch (state)
            {
                case Constants.MultiplayManagerState.CreateRoom:
                    _roomId = id;
                    _myTurnType = TurnType.PlayerA;
                    Debug.Log("## Create Room ##");
                    break;
                case Constants.MultiplayManagerState.JoinRoom:
                    Debug.Log("## Joining Room ##");
                    _roomId = id;
                    _myTurnType = TurnType.PlayerB;
                    break;
                case Constants.MultiplayManagerState.StartGame:

                    Debug.Log("## Start Game ##");
                    break;
                case Constants.MultiplayManagerState.EndGame:
                    Debug.Log("## End Game ##");
                    break;
                case Constants.MultiplayManagerState.BlockChanged:
                    Debug.Log("## Set Block ##");
                    int turn = (int)char.GetNumericValue(id[0]);
                    int x = (int)char.GetNumericValue(id[1]);
                    int y = (int)char.GetNumericValue(id[2]);
                    UnityThread.executeInUpdate(() =>
                    {
                        _blockController.PlaceMarker(turn <1 ? Block.MarkerType.O: Block.MarkerType.X, x, y);
                        SetNewBoardValue(turn < 1 ? PlayerType.PlayerA : PlayerType.PlayerB ,x, y);
                        SetTurn( turn < 1 ? TurnType.PlayerB : TurnType.PlayerA );
                    });
                    
                    break;
            }
        });
    }


    // 턴 시작
    SetTurn(TurnType.PlayerA);
}

 

startgame 부분에서 다음과 같이 새로운 매니저를 만들어주고 할당해준다. 

 

다음은 SetTurn의 일부분이다 (위쪽은 Singleplay일때 )

else
{
    _blockController.OnBlockClickedDelegate = null;

    var checkGameResult = CheckGameResult();
    if (checkGameResult != GameResult.None)
    {
        EndGame(checkGameResult);
        return;
    }

    
    switch (turnType)
    {
        case TurnType.PlayerA:
        {
            if (_myTurnType == TurnType.PlayerA)
            {
                _blockController.OnBlockClickedDelegate = (row, col) =>
                {
                    if (SetNewBoardValue(PlayerType.PlayerA, row, col))
                    {
                        _multiplayManager.ChangeBlock(_roomId,TurnType.PlayerA, row, col);

                        var gameResult = CheckGameResult();
                        if (gameResult == GameResult.None)
                        {
                            SetTurn(TurnType.PlayerB);
                            _blockController.OnBlockClickedDelegate = null;

                        }
                        else
                            EndGame(gameResult);
                    }
             
                };
            }
            break;
        }
        case TurnType.PlayerB:
        {
            if (_myTurnType == TurnType.PlayerB)
            {
                _blockController.OnBlockClickedDelegate = (row, col) =>
                {
                    if (SetNewBoardValue(PlayerType.PlayerB, row, col))
                    {
                        _multiplayManager.ChangeBlock(_roomId,TurnType.PlayerB, row, col);

                        var gameResult = CheckGameResult();
                        if (gameResult == GameResult.None)
                        {
                            SetTurn(TurnType.PlayerA);
                            _blockController.OnBlockClickedDelegate = null;

                        }
                        else
                            EndGame(gameResult);
                    }
             
                };
            }
           
        }
           
        break;
    }
}

이런 식으로 자신의 타입과 같을 때만 동작하도록 만들어준다. 

그리고 블럭을 놨다면, 상대방한테도 그 정보를 보내주는 것이다. 

 

헤맸던 부분 : 데이터 파싱 문제

오히려 코드를 짜는 부분보다 이 부분이 시간이 오래걸렸다. 

파싱이제대로안되었다, 처음엔 보내는 것도 안되었고, 두 번째는 가져오는 것도 제대로 안되었다. 

이건 따로 포스팅해서 JSON 데이터와 변환에 관해 써야겠다. 

일단 정답만 써 보자면 

 

public class BlockData
{
    public int turn { get; set; }
    public int x { get; set; }
    public int y { get; set; }
}

 

블럭데이터를 다음과 같은 형태로 Set 할 수 있게 만들어야 했고 

 

public void ChangeBlock(string roomId, GameManager.TurnType turnType, int x, int y)
{
    Debug.Log($"ChangeBlock으로 들어온 x, x : {x}, y : {y}");
    int turn = (turnType == GameManager.TurnType.PlayerB) ? 1 : 0;
    var data = new Dictionary<string, object>
    {
        { "roomId", roomId },
        { "turn", turn },
        { "x", x },
        { "y", y }
    };
    Debug.Log($"data = {JsonConvert.SerializeObject(data)}");
    _socket.Emit("changeBlock", data);
}

 

Dic형태로 만들어 전달해 주어야 했다. 익명은 전달이 안될수도 있다고 한다. 

자세한 이야기는 내일 포스팅에서 ! 

 

오늘의 목표

01. 동아리 지원서 작성

02. 코테 풀기 ( 하노이 탑 )