본문 바로가기
Development/C#

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

by Mobics 2025. 3. 10.

 

목차


    Tic Tac Toe 서버 만들기

    25.03.10

    멀티 플레이 구현

    >> Chatting Panel Prefab화 후, Hierarchy에서 삭제

     

    >> 'Main Panel'의 'Buttons'에 있는 'Score Button'을 'MultiplayButton'으로 수정

    : Text도 '멀티 플레이'로 변경, OnClick()에 함수 바인딩

     

    >> 기존 코드 개선

    : 멀티 플레이를 적용하기에 더 용이하도록 개선

    • MainPanelController.cs
    • GameManager.cs
    • AIController.cs --> 삭제
    • Constants.cs
    • BlockController.cs
    • MinimaxAIController.cs
    • BattlePanelController.cs --> 삭제

     

    >> GameLogic.cs 생성

    : GameManager에서 GameLogic을 분리, 상태 패턴 활용

     

    클라우드 서버 연결

    >> MongoDB

    1. Create a cluster

     

    2. Create a database user

    : Username과 Password 생성

     

    3. Connect

     

    4. 나온 deployment 링크를 복사하여 app.js파일의 MongoDB 관련 ConnectDB()에 복붙

    --> 아래 사진은 예시, <db_username>과 <db_password>에 <> 없이 각각 username과 password를 넣어야 한다.

     

    >> Koyeb

    1. 회원가입 후, Create Service --> Git 선택

     

    2. Github로 가서 tictactoe-server git의 HTTPS주소를 복사, public GitHub repository에 붙여넣기

    : public repository가 아니라면 Github와 연결하여 선택 가능

     

    3. Free 버전 선택

    : latency를 보고 국가 선택

     

    4. service 선택

    : Source에서 repository 선택

    --> 마찬가지로 public repository라면 URL로, 아니라면 연결한 것 선택, 이후 Deploy 클릭

     

    5. Build

    : 이것까지 완료되면 게임 서버가 배포될 것

    --> 자동으로 완료된다.

     

    6. Public URL 설정

    : client에 있는 서버 URL을 나온 Public URL로 바꿔야 한다.

    --> Overview에 있는 Public URL 복사

    --> Constants.cs에 있는 serverURL과 GameServerURL을 수정

    • ServerURL 앞의 'http://'와 뒤의 ':3000' 는 그대로 유지하고 중간에 붙여넣기
    • GameServerURL 앞의 'ws://'와 뒤의 ':3000' 는 그대로 유지하고 중간에 붙여넣기

     

    ※ Window로 테스트할 때 설정

    : 설정을 바꾼 뒤 Build

     

    최종 코드

    >> GameManager.cs

    using System;
    using System.Collections;
    using System.Collections.Generic;
    using UnityEngine;
    using UnityEngine.SceneManagement;
    
    public class GameManager : Singleton<GameManager>
    {
        [SerializeField] private GameObject settingsPanel;
        [SerializeField] private GameObject confirmPanel;
        [SerializeField] private GameObject signinPanel;
        [SerializeField] private GameObject signupPanel;
        
        private GameUIController _gameUIController;
        private Canvas _canvas;
        
        private Constants.GameType _gameType;
        private GameLogic _gameLogic;
    
        private void Start()
        {
            // 로그인
            //OpenSigninPanel();
        }
    
        public void ChangeToGameScene(Constants.GameType gameType)
        {
            _gameType = gameType;
            SceneManager.LoadScene("Game");
        }
    
        public void ChangeToMainScene()
        {
            _gameLogic?.Dispose();
            _gameLogic = null;
            SceneManager.LoadScene("Main");
        }
    
        public void OpenSettingsPanel()
        {
            if (_canvas != null)
            {
                var settingsPanelObject = Instantiate(settingsPanel, _canvas.transform);
                settingsPanelObject.GetComponent<PanelController>().Show();
            }
        }
    
        public void OpenConfirmPanel(string message, ConfirmPanelController.OnConfirmButtonClick onConfirmButtonClick)
        {
            if (_canvas != null)
            {
                var confirmPanelObject = Instantiate(confirmPanel, _canvas.transform);
                confirmPanelObject.GetComponent<ConfirmPanelController>()
                    .Show(message, onConfirmButtonClick);
            }
        }
    
        public void OpenSigninPanel()
        {
            if (_canvas != null)
            {
                var signinPanelObject = Instantiate(signinPanel, _canvas.transform);
            }
        }
    
        public void OpenSignupPanel()
        {
            if (_canvas != null)
            {
                var signupPanelObject = Instantiate(signupPanel, _canvas.transform);
            }
        }
    
        public void OpenGameOverPanel()
        {
            _gameUIController.SetGameUIMode(GameUIController.GameUIMode.GameOver);
        }
        
        protected override void OnSceneLoaded(Scene scene, LoadSceneMode mode)
        {
            if (scene.name == "Game")
            {
                // Scene에 배치된 Object 찾기 (BlockController, GameUIController)
                var blockController = GameObject.FindObjectOfType<BlockController>();
                _gameUIController = GameObject.FindObjectOfType<GameUIController>();
                
                // BlockController 초기화
                blockController.InitBlocks();
                
                // Game UI 초기화
                _gameUIController.SetGameUIMode(GameUIController.GameUIMode.Init);
                
                // Game Logic 객체 생성 --> 생성자 호출
                if (_gameLogic != null) _gameLogic.Dispose();
                _gameLogic = new GameLogic(blockController, _gameType);
            }
    
            // Canvas는 Main과 Game 모두 필요하기 때문에 if 밖에서 찾음
            _canvas = GameObject.FindObjectOfType<Canvas>();
        }
    
        private void OnApplicationQuit()
        {
            _gameLogic?.Dispose();
            _gameLogic = null;
        }
    }

     

    >> GameLogic.cs

    using System;
    using UnityEngine;
    
    public abstract class BasePlayerState
    {
        public abstract void OnEnter(GameLogic gameLogic);  // 해당 State가 할당될 때 처리
        public abstract void OnExit(GameLogic gameLogic);   // 해당 State가 교체될 때 처리
        public abstract void HandleMove(GameLogic gameLogic, int row, int col); // 해당 State의 행위에 따라 마커 표시
        protected abstract void HandleNextTurn(GameLogic gameLogic);    // State 전환
    
        // 마커 표시 및 결과 처리
        protected void ProcessMove(GameLogic gameLogic, Constants.PlayerType playerType, int row, int col)
        {
            if (gameLogic.SetNewBoardValue(playerType, row, col))
            {
                var gameResult = gameLogic.CheckGameResult();
    
                if (gameResult == GameLogic.GameResult.None)
                {
                    HandleNextTurn(gameLogic);
                }
                else
                {
                    gameLogic.EndGame(gameResult);
                }
            }
        }
    }
    
    // 직접 플레이 (싱글, 네트워크)
    public class PlayerState : BasePlayerState
    {
        private Constants.PlayerType _playerType;
        private bool _isFirstPlayer;
        
        private MultiplayManager _multiplayManager;
        private string _roomId;
        private bool _isMultiplay;
        
        public PlayerState(bool isFirstPlayer)
        {
            _isFirstPlayer = isFirstPlayer;
            _playerType = _isFirstPlayer ? Constants.PlayerType.PlayerA : Constants.PlayerType.PlayerB;
            _isMultiplay = false;
        }
    
        public PlayerState(bool isFirstPlayer, MultiplayManager multiplayManager, string roomId) : this(isFirstPlayer)
        {
            _multiplayManager = multiplayManager;
            _roomId = roomId;
            _isMultiplay = true;
        }
        
        public override void OnEnter(GameLogic gameLogic)
        {
            gameLogic.blockController.OnBlockClickedDelegate = (row, col) =>
            {
                HandleMove(gameLogic, row, col);
            };
        }
    
        public override void OnExit(GameLogic gameLogic)
        {
            gameLogic.blockController.OnBlockClickedDelegate = null; // 게임이 끝났을 때 board를 Click해도 바뀌지 않도록
        }
    
        public override void HandleMove(GameLogic gameLogic, int row, int col)
        {
            ProcessMove(gameLogic, _playerType, row, col);
            if (_isMultiplay)
            {
                _multiplayManager.SendPlayerMove(_roomId, row * 3 + col);
            }
        }
    
        protected override void HandleNextTurn(GameLogic gameLogic)
        {
            if (_isFirstPlayer)
            {
                gameLogic.SetState(gameLogic.secondPlayerState);
            }
            else
            {
                gameLogic.SetState(gameLogic.firstPlayerState);
            }
        }
    }
    
    // AI 플레이
    public class AIState : BasePlayerState
    {
        public override void OnEnter(GameLogic gameLogic)
        {
            // AI 연산
            var result = MinimaxAIController.GetBestMove(gameLogic.GetBoard());
            if (result.HasValue)
            {
                HandleMove(gameLogic, result.Value.row, result.Value.col);
            }
            else
            {
                gameLogic.EndGame(GameLogic.GameResult.Draw);
            }
        }
    
        public override void OnExit(GameLogic gameLogic)
        {
        }
    
        public override void HandleMove(GameLogic gameLogic, int row, int col)
        {
            ProcessMove(gameLogic, Constants.PlayerType.PlayerB, row, col);
        }
    
        protected override void HandleNextTurn(GameLogic gameLogic)
        {
            gameLogic.SetState(gameLogic.firstPlayerState);
        }
    }
    
    // 네트워크 플레이 --> 서버로부터 상대 Player를 기다림
    public class MultiplayState : BasePlayerState
    {
        private Constants.PlayerType _playerType;
        private bool _isFirstPlayer;
        
        private MultiplayManager _multiplayManager;
    
        public MultiplayState(bool isFirstPlayer, MultiplayManager multiplayManager)
        {
            _isFirstPlayer = isFirstPlayer;
            _playerType = _isFirstPlayer ? Constants.PlayerType.PlayerA : Constants.PlayerType.PlayerB;
            _multiplayManager = multiplayManager;
        }
        
        public override void OnEnter(GameLogic gameLogic)
        {
            _multiplayManager.OnOpponentMove = moveData =>
            {
                var row = moveData.position / 3;
                var col = moveData.position % 3;
                UnityThread.executeInUpdate(() =>
                {
                    HandleMove(gameLogic, row, col);
                });
            };
        }
    
        public override void OnExit(GameLogic gameLogic)
        {
            _multiplayManager.OnOpponentMove = null;
        }
    
        public override void HandleMove(GameLogic gameLogic, int row, int col)
        {
            ProcessMove(gameLogic, _playerType, row, col);
        }
    
        protected override void HandleNextTurn(GameLogic gameLogic)
        {
            if (_isFirstPlayer)
            {
                gameLogic.SetState(gameLogic.secondPlayerState);
            }
            else
            {
                gameLogic.SetState(gameLogic.firstPlayerState);
            }
        }
    }
    
    public class GameLogic : IDisposable
    {
        public BlockController blockController;
        private Constants.PlayerType[,] _board;
        
        public BasePlayerState firstPlayerState;      // 첫 번째 턴 상태 객체
        public BasePlayerState secondPlayerState;     // 두 번째 턴 상태 객체
        private BasePlayerState _currentPlayerState;  // 현재 턴 상태 객체
        
        private MultiplayManager _multiplayManager;
        private string _roomId;
        
        public enum GameResult { None, Win, Lose, Draw }
        
        public GameLogic(BlockController blockController, Constants.GameType gameType,
            MultiplayManager multiplayManager = null, string roomId = null, bool isFirstPlayer = true)
        {
            this.blockController = blockController;
            
            // _board 초기화
            _board = new Constants.PlayerType[3, 3];
    
            switch (gameType)
            {
                case Constants.GameType.SinglePlayer:
                    firstPlayerState = new PlayerState(true);
                    secondPlayerState = new AIState();
                    // 게임 시작
                    SetState(firstPlayerState);
                    break;
                case Constants.GameType.DualPlayer:
                    firstPlayerState = new PlayerState(true);
                    secondPlayerState = new PlayerState(false);
                    // 게임 시작
                    SetState(firstPlayerState);
                    break;
                case Constants.GameType.MultiPlayer:
                    // Multiplay Manager 생성
                    _multiplayManager = new MultiplayManager((state, roomId) =>
                    {
                        _roomId = roomId;
                        switch (state)
                        {
                            case Constants.MultiplayManagerState.CreateRoom:
                                Debug.Log("## Create Room ##");
                                // TODO: 대기 화면 표시
                                // GameManager에게 대기 화면 표시 요청
                                break;
                            case Constants.MultiplayManagerState.JoinRoom: // 게임 실행 --> Room에 입장한 사람
                                Debug.Log("## Join Room ##");
                                firstPlayerState = new MultiplayState(true, _multiplayManager);
                                secondPlayerState = new PlayerState(false, _multiplayManager, roomId);
                                // 게임 시작
                                SetState(firstPlayerState);
                                break;
                            case Constants.MultiplayManagerState.ExitRoom:
                                Debug.Log("## Exit Room ##");
                                // TODO: Exit Room 처리
                                break;
                            case Constants.MultiplayManagerState.StartGame: // 대기 화면을 닫고 게임 실행 --> Create Room을 한 사람
                                Debug.Log("## Start Game ##");
                                firstPlayerState = new PlayerState(true, _multiplayManager, roomId);
                                secondPlayerState = new MultiplayState(false, _multiplayManager);
                                // 게임 시작
                                SetState(firstPlayerState);
                                break;
                            case Constants.MultiplayManagerState.EndGame:
                                Debug.Log("## End Game ##");
                                // TODO: End Game 처리
                                break;
                        }
                    });
                    break;
            }
        }
    
        public void SetState(BasePlayerState state)
        {
            _currentPlayerState?.OnExit(this);
            _currentPlayerState = state;
            _currentPlayerState?.OnEnter(this);
        }
        
        /// <summary>
        /// _board에 새로운 값을 할당하는 함수
        /// </summary>
        /// <param name="playerType">할당하고자 하는 플레이어 타입</param>
        /// <param name="row">Row</param>
        /// <param name="col">Col</param>
        /// <returns>False : 할당할 수 없음, True : 할당이 완료됨</returns>
        public bool SetNewBoardValue(Constants.PlayerType playerType, int row, int col)
        {
            if (_board[row, col] != Constants.PlayerType.None) return false; // 중복 체크 방지
            
            if (playerType == Constants.PlayerType.PlayerA)
            {
                _board[row, col] = playerType;
                blockController.PlaceMarker(Block.MarkerType.O, row, col);
                return true;
            }
            else if (playerType == Constants.PlayerType.PlayerB)
            {
                _board[row, col] = playerType;
                blockController.PlaceMarker(Block.MarkerType.X, row, col);
                return true;
            }
            return false;
        }
        
        /// <summary>
        /// 게임 결과 확인 함수
        /// </summary>
        /// <returns>플레이어 기준 게임 결과</returns>
        public GameResult CheckGameResult()
        {
            if (CheckGameWin(Constants.PlayerType.PlayerA)) return GameResult.Win;
            if (CheckGameWin(Constants.PlayerType.PlayerB)) return GameResult.Lose;
            if (MinimaxAIController.IsAllBlocksPlaced(_board)) return GameResult.Draw;
            
            return GameResult.None;
        }
        
        // 게임의 승패를 판단하는 함수
        private bool CheckGameWin(Constants.PlayerType playerType)
        {
            // 가로로 마커가 일치하는지 확인 
            for (var row = 0; row < _board.GetLength(0); row++)
            {
                if (_board[row, 0] == playerType && _board[row, 1] == playerType && _board[row, 2] == playerType)
                {
                    (int, int)[] blocks = { (row, 0), (row, 1), (row, 2) };
                    blockController.SetBlockColor(playerType, blocks);
                    return true;
                }
            }
            
            // 세로로 마커가 일치하는지 확인
            for (var col = 0; col < _board.GetLength(1); col++)
            {
                if (_board[0, col] == playerType && _board[1, col] == playerType && _board[2, col] == playerType)
                {
                    (int, int)[] blocks = { (0, col), (1, col), (2, col) };
                    blockController.SetBlockColor(playerType, blocks);
                    return true;
                }
            }
            
            // 대각선으로 마커가 일치하는지 확인
            if (_board[0, 0] == playerType && _board[1, 1] == playerType && _board[2, 2] == playerType)
            {
                (int, int)[] blocks = { (0, 0), (1, 1), (2, 2) };
                blockController.SetBlockColor(playerType, blocks);
                return true;
            }
    
            if (_board[0, 2] == playerType && _board[1, 1] == playerType && _board[2, 0] == playerType)
            {
                (int, int)[] blocks = { (0, 2), (1, 1), (2, 0) };
                blockController.SetBlockColor(playerType, blocks);
                return true;
            }
            
            return false;
        }
        
        /// <summary>
        /// 게임 오버 시, 호출되는 함수
        /// gameResult에 따라 결과 출력
        /// </summary>
        /// <param name="gameResult">win, lose, draw</param>
        public void EndGame(GameResult gameResult)
        {
            SetState(null);
    
            firstPlayerState = null;
            secondPlayerState = null;
            
            // 게임 오버 표시
            GameManager.Instance.OpenGameOverPanel();
        }
        
        public Constants.PlayerType[,] GetBoard()
        {
            return _board;
        }
    
        public void Dispose()
        {
            _multiplayManager?.LeaveRoom(_roomId);
            _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 MoveData
    {
        [JsonProperty("position")]
        public int position { 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<MoveData> OnOpponentMove;
        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("exitRoom", ExitRoom);
            _socket.On("startGame", StartGame);
            _socket.On("endGame", EndGame);
            _socket.On("doOpponent", DoOpponent);
            _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 ExitRoom(SocketIOResponse response)
        {
            _onMultiplayStateChanged?.Invoke(Constants.MultiplayManagerState.ExitRoom, null);
        }
    
        private void StartGame(SocketIOResponse response)
        {
            var data = response.GetValue<RoomData>();
            _onMultiplayStateChanged?.Invoke(Constants.MultiplayManagerState.StartGame, data.roomId);
        }
    
        private void EndGame(SocketIOResponse response)
        {
            _onMultiplayStateChanged?.Invoke(Constants.MultiplayManagerState.EndGame, null);
        }
    
        // 서버로부터 상대방의 마커 정보를 받기 위한 Method
        private void DoOpponent(SocketIOResponse response)
        {
            var data = response.GetValue<MoveData>();
            OnOpponentMove?.Invoke(data);
        }
    
        // Player의 마커 위치를 서버로 전달하기 위한 Method
        public void SendPlayerMove(string roomId, int position)
        {
            _socket.Emit("doPlayer", new { roomId, position });
        }
    
        // 서버로부터 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 LeaveRoom(string roomId)
        {
            _socket.Emit("leaveRoom", new { roomId });
        }
    
        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,       // 생성된 방에 참여
            ExitRoom,       // 자신이 방을 빠져 나왔을 때
            StartGame,      // 생성한 방에 다른 유저가 참여하여 게임 시작
            EndGame         // 상대방이 접속을 끊거나 방을 나갔을 때
        };
        
        public enum PlayerType { None, PlayerA, PlayerB }
        public enum GameType { SinglePlayer, DualPlayer, MultiPlayer }
    }

     

    >> BlockController.cs

    using UnityEngine;
    
    public class BlockController : MonoBehaviour
    {
        [SerializeField] private Block[] blocks;
        
        public delegate void OnBlockClicked(int row, int col);
        public OnBlockClicked OnBlockClickedDelegate;
    
        public void InitBlocks()
        {
            for (int i = 0; i < blocks.Length; i++)
            {
                blocks[i].InitMarker(i, blockIndex =>
                {
                    var clickedRow = blockIndex / 3;
                    var clickedCol = blockIndex % 3;
                    
                    OnBlockClickedDelegate?.Invoke(clickedRow, clickedCol);
                });
            }
        }
    
        /// <summary>
        /// 특정 Block에 Marker를 표시하는 함수
        /// </summary>
        /// <param name="markerType">마커 타입</param>
        /// <param name="row">Row</param>
        /// <param name="col">Col</param>
        public void PlaceMarker(Block.MarkerType markerType, int row, int col)
        {
            // row, col을 index로 변환
            var markerIndex = row * 3 + col;
            
            // Block에게 마커 표시
            blocks[markerIndex].SetMarker(markerType);
        }
    
        public void SetBlockColor(Constants.PlayerType playerType, (int row, int col)[] blockPositions)
        {
            if (playerType == Constants.PlayerType.None) return;
    
            foreach (var blockPosition in blockPositions)
            {
                var blockIndex = blockPosition.row * 3 + blockPosition.col;
                Color32 markerColor;
                if (playerType == Constants.PlayerType.PlayerA)
                    markerColor = new Color32(0,  166, 255, 255);
                else if (playerType == Constants.PlayerType.PlayerB)
                    markerColor = new Color32(255, 0, 94, 255);
                else
                    markerColor = Color.black;
                
                blocks[blockIndex].SetColor(markerColor);
            }
        }
    }

     

    >> MinimaxAIController.cs

    using System;
    using System.Collections;
    using System.Collections.Generic;
    using UnityEngine;
    
    public static class MinimaxAIController
    {
        public static void printBoard(Constants.PlayerType[,] board)
        {
            string boardString = "";
            for (int row = 0; row < 3; row++)
            {
                for (int col = 0; col < 3; col++)
                {
                    boardString += $"{(int)board[row, col]} ";  // PlayerType 값을 숫자로 출력
                }
                boardString += "\n";  // 한 줄 출력 후 줄 바꿈
            }
            Debug.Log("\n" + boardString);  // 콘솔 창에 보드 출력
        }
        
        public static (int row, int col)? GetBestMove(Constants.PlayerType[,] board)
        {
            float bestScore = -1000;
            (int row, int col)? bestMove = null;
            
            for (var row = 0; row < board.GetLength(0); row++)
            {
                for (var col = 0; col < board.GetLength(1); col++)
                {
                    if (board[row, col] == Constants.PlayerType.None)
                    {
                        board[row, col] = Constants.PlayerType.PlayerB;
                        var score = DoMinimax(board, 0, false);
                        board[row, col] = Constants.PlayerType.None;
    
                        if (score > bestScore)
                        {
                            bestScore = score;
                            bestMove = (row, col);
                        }
                    }
                }
            }
            return bestMove;
        }
        
        private static float DoMinimax(Constants.PlayerType[,] board, int depth, bool isMaximizing)
        {
            if (CheckGameWin(Constants.PlayerType.PlayerA, board))
                return -10 + depth;
            if (CheckGameWin(Constants.PlayerType.PlayerB, board))
                return 10 - depth;
            if (IsAllBlocksPlaced(board))
                return 0;
    
            if (isMaximizing)
            {
                var bestScore = float.MinValue;
                for (var row = 0; row < board.GetLength(0); row++)
                {
                    for (var col = 0; col < board.GetLength(1); col++)
                    {
                        if (board[row, col] == Constants.PlayerType.None)
                        {
                            board[row, col] = Constants.PlayerType.PlayerB;
                            var score = DoMinimax(board, depth + 1, false); // 재귀함수
                            board[row, col] = Constants.PlayerType.None;
                            bestScore = Math.Max(bestScore, score); // Math.Max(a, b) : a와 b 중에 큰 값을 반환
                        }
                    }
                }
                return bestScore;
            }
            else
            {
                var bestScore = float.MaxValue;
                for (var row = 0; row < board.GetLength(0); row++)
                {
                    for (var col = 0; col < board.GetLength(1); col++)
                    {
                        if (board[row, col] == Constants.PlayerType.None)
                        {
                            board[row, col] = Constants.PlayerType.PlayerA;
                            var score = DoMinimax(board, depth + 1, true); // 재귀함수
                            board[row, col] = Constants.PlayerType.None;
                            bestScore = Math.Min(bestScore, score); // Math.Min(a, b) : a와 b 중에 작은 값을 반환
                        }
                    }
                }
                return bestScore;
            }
        }
        
        /// <summary>
        /// 모든 마커가 보드에 배치 되었는지 확인하는 함수
        /// </summary>
        /// <returns>True : 모두 배치</returns>
        public static bool IsAllBlocksPlaced(Constants.PlayerType[,] board)
        {
            for (var row = 0; row < board.GetLength(0); row++)
            {
                for (var col = 0; col < board.GetLength(1); col++)
                {
                    if (board[row, col] == Constants.PlayerType.None)
                        return false;
                }
            }
            return true;
        }
        
        /// <summary>
        /// 게임의 승패를 판단하는 함수
        /// </summary>
        /// <param name="playerType"></param>
        /// <param name="board"></param>
        /// <returns></returns>
        private static bool CheckGameWin(Constants.PlayerType playerType, Constants.PlayerType[,] board)
        {
            // 가로로 마커가 일치하는지 확인 
            for (var row = 0; row < board.GetLength(0); row++)
            {
                if (board[row, 0] == playerType && board[row, 1] == playerType && board[row, 2] == playerType)
                    return true;
            }
            
            // 세로로 마커가 일치하는지 확인
            for (var col = 0; col < board.GetLength(1); col++)
            {
                if (board[0, col] == playerType && board[1, col] == playerType && board[2, col] == playerType)
                    return true;
            }
            
            // 대각선으로 마커가 일치하는지 확인
            if (board[0, 0] == playerType && board[1, 1] == playerType && board[2, 2] == playerType)
                return true;
    
            if (board[0, 2] == playerType && board[1, 1] == playerType && board[2, 0] == playerType)
                return true;
            
            return false;
        }
    }

     

    >> MainPanelController.cs

    using System.Collections;
    using System.Collections.Generic;
    using UnityEngine;
    using UnityEngine.SceneManagement;
    
    public class MainPanelController : MonoBehaviour
    {
        public void OnClickSinglePlayButton()
        {
            GameManager.Instance.ChangeToGameScene(Constants.GameType.SinglePlayer);
        }
        
        public void OnClickDualPlayButton()
        {
            GameManager.Instance.ChangeToGameScene(Constants.GameType.DualPlayer);
        }
    
        public void OnClickMultiplayButton()
        {
            GameManager.Instance.ChangeToGameScene(Constants.GameType.MultiPlayer);
        }
        
        public void OnClickSettingsButton()
        {
            GameManager.Instance.OpenSettingsPanel();
        }
    }

     

    >> game.js

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

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

        var rooms = [];
        var socketRooms = new Map();

        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', { roomId: roomId });
                socketRooms.set(socket.id, roomId);
            } else {
                var roomId = uuidv4();
                socket.join(roomId);
                socket.emit('createRoom', { roomId: roomId });
                rooms.push(roomId);
                socketRooms.set(socket.id, roomId);
            }

            socket.on('leaveRoom', function(roomData) {
                socket.leave(roomData.roomId);
                socket.emit('exitRoom');
                socket.to(roomData.roomId).emit('endGame');

                // 방 만든 후 혼자 들어갔다가 나갈 때, rooms에서 해당 방 정보 삭제
                var roomId = socketRooms.get(socket.id);
                const roomIdx = rooms.indexOf(roomId);
                if (roomIdx !== -1) {
                    rooms.splice(roomIdx, 1);
                    console.log('방 삭제됨 : ' + roomId);
                }
                socketRooms.delete(socket.id);
            });

            socket.on('doPlayer', function(moveData) {
                const roomId = moveData.roomId;
                const position = moveData.position;
                console.log('doPlayer 메시지를 받았습니다: ' + roomId + ' ' + position);
                socket.to(roomId).emit('doOpponent', { position: position });
            });

            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('사용자가 연결을 해제했습니다.');
            });
        });
    }