본문 바로가기
Development/C#

멋쟁이사자처럼부트캠프 Unity 게임 개발 3기 69일차

by Mobics 2025. 3. 7.

 

목차


    Tic Tac Toe 서버 만들기

    25.03.07

    채팅 구현

    > 키보드가 올라오면 Input Field도 위로 올라오도록 구현해야 함

     

    >> Input Field 이름을 'Message Input Field'로 변경

    : Input Field와 Text의 이름에 있는 (TMP) 전부 삭제 --> 과거에는 Legacy와 혼용되어 사용되었기 때문에 구분하기 위해 썼지만 지금은 TMP만 사용하므로 구분할 필요가 없음

     

    >> 'Content'의 자식으로 있는 Text 이름을 'Message Text'로 변경 후, Prefab화

    : 이후 Hierarchy에 있는 Message Text 삭제

     

    >> ChattingPanelController.cs 생성

     

    >> Chatting Panel에 ChattingPanelController.cs 추가 및 바인딩

     

    >> Message InputField의 OnEndEdit(string)에 함수 추가 및 바인딩

     

    멀티 플레이로 채팅 예제 만들기

    : SocketIO를 활용

     

    >> MultiPlayManager.cs 생성

     

    ※ 테스트 하기 위해 ParrelSync를 이용해 Clone을 만드는 중 발생한 오류

    : ParrelSync로 Clone을 만드는 과정에서 자꾸 에러가 떠서 Clone이 안 된다.

    --> 강사님 답변 : Build를 하거나 프로젝트를 2개로 띄우는 다른 방법을 찾아서 테스트 해볼 것

    --> 수강생님이 알려주신 방법 : Unity6에서 공식지원하는 'Multiplayer Play Mode' 를 Package Managre Pre-release를 허용하여 가져올 수 있다.

    --> 근데 Package Manager에 Mulitplayer Play Mode가 검색해도 보이질 않는 문제...

     

    >> 해결 방법

    : ChatGPT에 물어봤을 때, 관리자 권한의 문제였다는 것을 알게됐고 프로젝트를 종료한 다음 UnityHub를 관리자 권한으로 실행하여 프로젝트를 다시 열어서 ParrelSync로 Clone을 만드니까 성공했다.

     

    ※ Unity의 Thread 관련

    : Unity는 기본적으로 싱글 스레드 환경에서 동작한다. 즉, 대부분의 경우 메인 스레드에서 동작한다. 하지만 Socket.IO는 네트워크 이벤트를 처리할 때 별도의 네트워크 스레드에서 실행되기 때문에 Socket.IO 내에서 Unity의 메인 스레드에서만 실행 가능한 작업(예: UI 업데이트, GameObject 변경 등)을 실행하면 문제가 발생할 수 있다.

    --> 따라서 별도의 네트워크 스레드에서 실행된 콜백을 Unity의 메인 스레드에서 실행하도록 다음과 같은 두 가지 방법으로 작업을 옮겨줘야 한다.

    • OnUnityThread() : 다른 스레드에서 실행된 코드를 Unity의 메인 스레드에서 '즉시' 실행되도록 한다.
    • executeInUpdate() : Unity의 Update()에서 실행되도록 예약하는 방법으로, 특정 작업을 '다음 프레임'에서 실행할 수 있도록 큐에 추가한다. --> OnUnityThread()와 비슷하지만, 단순히 Update()에서 실행되도록 보장하는 점이 다르다.

     

    ※ Postman으로 서버 테스트를 할 수 있다.

     

    활동

    :  Tic Tac Toe 멀티플레이 만들기

    • 멀티플레이가 가능한 Tic Tac Toe를 만들어서 영상을 올려주세요

     

    최종 코드

    >> ChattingPanelController.cs

    using System;
    using System.Collections;
    using System.Collections.Generic;
    using TMPro;
    using UnityEngine;
    
    public class ChattingPanelController : MonoBehaviour
    {
        [SerializeField] private TMP_InputField messageInputField;
        [SerializeField] private GameObject messageTextPrefab;
        [SerializeField] private Transform messageTextParent;
    
        private MultiplayManager _multiplayManager;
        private string _roomId;
    
        public void OnEndEditInputField(string messageText)
        {
            var messageTextObject = Instantiate(messageTextPrefab, messageTextParent);
            messageTextObject.GetComponent<TMP_Text>().text = messageText;
            messageInputField.text = "";    // message를 보내고 나면 InputField를 비워서 다음 message를 받을 수 있도록
    
            if (_roomId != null && _multiplayManager != null)
            {
                // TODO: 임의로 넣은 "홍길동" 대신 로그인할 때 받아온 User의 nickName을 넣어주기
                _multiplayManager.SendMessage(_roomId, "홍길동", messageText);
            }
        }
    
        private void Start()
        {
            messageInputField.interactable = false;
            _multiplayManager = new MultiplayManager((state, id) =>
            {
                switch (state)
                {
                    case Constants.MultiplayManagerState.CreateRoom:
                        Debug.Log("## Create Room ##");
                        _roomId = id;
                        break;
                    case Constants.MultiplayManagerState.JoinRoom:
                        Debug.Log("## Join Room ##");
                        _roomId = id;
                        UnityThread.executeInUpdate(() => messageInputField.interactable = true);
                        break;
                    case Constants.MultiplayManagerState.StartGame:
                        Debug.Log("## Start Game ##");
                        UnityThread.executeInUpdate(() => messageInputField.interactable = true);
                        break;
                    case Constants.MultiplayManagerState.EndGame:
                        Debug.Log("## End Game ##");
                        break;
                }
            });
            _multiplayManager.OnReceivedMessage = OnReceiveMessage;
        }
    
        private void OnReceiveMessage(MessageData messageData)
        {
            UnityThread.executeInUpdate(() =>
            {
                var messageTextObject = Instantiate(messageTextPrefab, messageTextParent);
                messageTextObject.GetComponent<TMP_Text>().text = messageData.nickName + " : " + messageData.message;
            });
        }
    
        private void OnApplicationQuit()
        {
            _multiplayManager.Dispose();
        }
    }

     

    >> MultiplayManager.cs

    using System;
    using System.Collections;
    using System.Collections.Generic;
    using Newtonsoft.Json;
    using UnityEngine;
    using SocketIOClient;
    
    // game.js 에서 보내는 값을 받음
    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;
        private event Action<Constants.MultiplayManagerState, string> _onMultiplayStateChanged;
        public Action<MessageData> OnReceivedMessage;
    
        public MultiplayManager(Action<Constants.MultiplayManagerState, string> onMultiplayStateChanged)
        {
            _onMultiplayStateChanged = onMultiplayStateChanged;
            
            var uri = new Uri(Constants.GameServerURL);
            _socket = new SocketIOUnity(uri, new SocketIOOptions
            {
                Transport = SocketIOClient.Transport.TransportProtocol.WebSocket
            });
            
            // 서버가 보낼 event(message)에 따라 작동
            // "createRoom"이라는 event가 왔을 때 CreateRoom이 작동하도록 CreateRoom()이라고 적지 않는다.
            _socket.On("createRoom", CreateRoom);
            _socket.On("joinRoom", JoinRoom);
            _socket.On("startGame", StartGame);
            _socket.On("gameEnded", GameEnded);
            _socket.On("receiveMessage", ReceiveMessage);
            
            _socket.Connect();
        }
    
        private void CreateRoom(SocketIOResponse response)  // 매개변수로 SocketIOResponse가 필수로 있어야 한다.
        {
            var data = response.GetValue<RoomData>();
            _onMultiplayStateChanged?.Invoke(Constants.MultiplayManagerState.CreateRoom, data.roomId);
        }
    
        private void JoinRoom(SocketIOResponse response)
        {
            var data = response.GetValue<RoomData>();
            _onMultiplayStateChanged?.Invoke(Constants.MultiplayManagerState.JoinRoom, data.roomId);
        }
    
        private void StartGame(SocketIOResponse response)
        {
            var data = response.GetValue<UserData>();
            _onMultiplayStateChanged?.Invoke(Constants.MultiplayManagerState.StartGame, data.userId);
        }
    
        private void GameEnded(SocketIOResponse response)
        {
            var data = response.GetValue<UserData>();
            _onMultiplayStateChanged?.Invoke(Constants.MultiplayManagerState.EndGame, data.userId);
        }
    
        // 서버로부터 Message를 받음
        private void ReceiveMessage(SocketIOResponse response)
        {
            var data = response.GetValue<MessageData>();
            OnReceivedMessage?.Invoke(data);
        }
    
        // 서버로 Message를 보냄
        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;
            }
        }
    }

     

    >> Constants.cs

    public class Constants
    {
        public const string ServerURL = "http://localhost:3000";
        public const string GameServerURL = "ws://localhost:3000";
    
        public enum MultiplayManagerState
        {
            CreateRoom,
            JoinRoom,
            StartGame,
            EndGame
        };
    }

     

    >> game.js

    const { v4: uuidv4 } = require('uuid');

    module.exports = function(server) {
        const io = require('socket.io')(server);

        var rooms = [];

        io.on('connection', function(socket) {
            // 클라이언트가 연결되면 실행되는 이벤트 핸들러
            console.log('사용자가 연결 되었습니다.');

            if (rooms.length > 0) {
                var roomId = rooms.shift();
                socket.join(roomId);
                socket.emit('joinRoom', { roomId: roomId });
                socket.to(roomId).emit('startGame', { userId: socket.id });
            } else {
                var roomId = uuidv4();
                socket.join(roomId);
                socket.emit('createRoom', { roomId: roomId });
                rooms.push(roomId);
            }

            socket.on('leaveRoom', function() {
                socket.leave(roomId);
                socket.emit('leaveRoom', { roomId: roomId });
                socket.to(roomId).emit('gameEnded', { userId: socket.id });
            });

            socket.on('sendMessage', function(message) {
                console.log('메세지를 받았습니다 : ' + message.roomId + ' ' + message.nickName + ' : ' + message.message);
                socket.to(message.roomId).emit('receiveMessage', { nickName: message.nickName, message: message.message });
            });

            socket.on('disconnect', function() {
                console.log('사용자가 연결을 해제했습니다.');

                var socketRooms = Array.from(socket.rooms).filter(room => room !== socket.id);

                socketRooms.forEach(function(roomId) {
                    socket.to(roomId).emit('gameEnded', { userId: socket.id })

                    const roomSize = io.sockets.adapter.rooms.get(roomId).size || 0;
                    if (roomSize <= 1) {
                        const idx = rooms.indexOf(roomId);
                        if (idx !== -1) {
                            rooms.splice(idx, 1);
                        }
                    }
                });
            });
        });
    }