본문 바로가기
Development/C#

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

by Mobics 2025. 2. 7.

 

목차


    틱택토 게임 만들기

    25.02.07

    Nullable

    :  null을 할당 할 수 없는 타입도 null로 할당 할 수 있도록 만들어 준다.

    --> 사용법 : 변수 타입 뒤에 '?' 붙이기

    --> 값을 받을 땐 '.Value'를 붙여서 받아야 한다.

     

    ※ HasValue : 값이 있는지 체크해서 bool 값으로 반환

     

    >> 예시

    private int? Test()
    {
    	return null;
    }
    
    private int? Result()
    {
    	var result = Test();
        if (result.HasValue) return result.Value;
    }

     

    Minimax 알고리즘 제작

    : 나의 차례에는 최선의 선택을, 상대방의 차례에는 나에게 최악의 선택을 예상하여 나에게는 최소의 영향을 끼치도록 함

    --> AI의 차례에는 Maximaizer 값을, Player의 차례에는 Minimizer 값을 가져옴

    https://medium.com/analytics-vidhya/mini-max-acc8d6b88cb1

    >> 재귀함수

    ※ 재귀함수 테스트 코드

    public static int TestFunc(int i)
    {
        if (i > 3) return i;
        var result = TestFunc(i + 1);
        return result;
    }
    
    public static void Test()
    {
    	Debug.Log(TestFunc(0));
    }

     

    ※ Log창에 띄워서 편하게 확인하는 코드

    : 근데 이 함수를 어디에서 호출하지..?

    public static void printBoard(GameManager.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);  // 콘솔 창에 보드 출력
    }

     

    ※ 알고리즘이 동작하는 것을 눈으로 보기 좋은 무료 예제

    https://assetstore.unity.com/packages/tools/behavior-ai/tictactoe-with-minmax-ai-220974?srsltid=AfmBOopt7OpeColJXoAVXKfLXookY6GuDc99Gy1QovS8YYnuSeGanM4Q

     

    TicTacToe with MinMax AI | 행동 AI | Unity Asset Store

    Get the TicTacToe with MinMax AI package from BadToxic (Michael Grönert) and speed up your game development process. Find this & other 행동 AI options on the Unity Asset Store.

    assetstore.unity.com

     

    최종 코드

    >> 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;
        
        private BlockController _blockController;
        private GameUIController _gameUIController;
        private Canvas _canvas;
    
        public enum PlayerType { None, PlayerA, PlayerB }
        private PlayerType[,] _board;
        
        public enum TurnType { PlayerA, PlayerB }
        private enum GameResult { None, Win, Lose, Draw }
        
        public enum GameType { SinglePlayer, DualPlayer }
    
        public void ChangeToGameScene(GameType gameType)
        {
            SceneManager.LoadScene("Game");
        }
    
        public void ChangeToMainScene()
        {
            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);
            }
        }
    
        /// <summary>
        /// 게임 시작
        /// </summary>
        private void StartGame()
        {
            // _board 초기화
            _board = new PlayerType[3, 3];
            
            // Block 초기화
            _blockController.InitBlocks();
            
            // Game UI 초기화
            _gameUIController.SetGameUIMode(GameUIController.GameUIMode.Init);
            
            //panelManager.ShowPanel(PanelManager.PanelType.BattlePanel);
            
            // 턴 시작
            SetTurn(TurnType.PlayerA);
        }
        
        /// <summary>
        /// 게임 오버 시, 호출되는 함수
        /// gameResult에 따라 결과 출력
        /// </summary>
        /// <param name="gameResult">win, lose, draw</param>
        private void EndGame(GameResult gameResult)
        {
            // 게임오버 표시
            _gameUIController.SetGameUIMode(GameUIController.GameUIMode.GameOver);
            _blockController.OnBlockClickedDelegate = null; // 게임이 끝났을 때 board를 Click해도 바뀌지 않도록
            
            // TODO: 나중에 구현!
            switch (gameResult)
            {
                case GameResult.Win:
                    break;
                case GameResult.Lose:
                    break;
                case GameResult.Draw:
                    break;
            }
        }
    
        /// <summary>
        /// _board에 새로운 값을 할당하는 함수
        /// </summary>
        /// <param name="playerType">할당하고자 하는 플레이어 타입</param>
        /// <param name="row">Row</param>
        /// <param name="col">Col</param>
        /// <returns>False : 할당할 수 없음, True : 할당이 완료됨</returns>
        private bool SetNewBoardValue(PlayerType playerType, int row, int col)
        {
            if (_board[row, col] != PlayerType.None) return false; // 중복 체크 방지
            
            if (playerType == PlayerType.PlayerA)
            {
                _board[row, col] = playerType;
                _blockController.PlaceMarker(Block.MarkerType.O, row, col);
                return true;
            }
            else if (playerType == PlayerType.PlayerB)
            {
                _board[row, col] = playerType;
                _blockController.PlaceMarker(Block.MarkerType.X, row, col);
                return true;
            }
            return false;
        }
    
        private void SetTurn(TurnType turnType)
        {
            switch (turnType)
            {
                case TurnType.PlayerA:
                    _gameUIController.SetGameUIMode(GameUIController.GameUIMode.TurnA);
                    _blockController.OnBlockClickedDelegate = (row, col) =>
                    {
                        if (SetNewBoardValue(PlayerType.PlayerA, row, col))
                        {
                            var gameResult = CheckGameResult();
                            if (gameResult == GameResult.None)
                                SetTurn(TurnType.PlayerB);
                            else
                                EndGame(gameResult);
                        }
                    };
                    break;
                case TurnType.PlayerB:
                    _gameUIController.SetGameUIMode(GameUIController.GameUIMode.TurnB);
                    
                    //var result = AIController.FindNextMove(_board);
                    var result = MinimaxAIController.GetBestMove(_board);
                    if (result.HasValue)
                    {
                        if (SetNewBoardValue(PlayerType.PlayerB, result.Value.row, result.Value.col))
                        {
                            var gameResult = CheckGameResult();
                            if (gameResult == GameResult.None)
                                SetTurn(TurnType.PlayerA);
                            else
                                EndGame(gameResult);
                        }
                    }
                    else
                    {
                        EndGame(GameResult.Win);
                    }
                    
                    break;
            }
        }
    
        /// <summary>
        /// 게임 결과 확인 함수
        /// </summary>
        /// <returns>플레이어 기준 게임 결과</returns>
        private GameResult CheckGameResult()
        {
            if (CheckGameWin(PlayerType.PlayerA)) return GameResult.Win;
            if (CheckGameWin(PlayerType.PlayerB)) return GameResult.Lose;
            if (MinimaxAIController.IsAllBlocksPlaced(_board)) return GameResult.Draw;
            
            return GameResult.None;
        }
        
        // 게임의 승패를 판단하는 함수
        private bool CheckGameWin(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;
        }
    
        protected override void OnSceneLoaded(Scene scene, LoadSceneMode mode)
        {
            if (scene.name == "Game")
            {
                _blockController = GameObject.FindObjectOfType<BlockController>();
                _gameUIController = GameObject.FindObjectOfType<GameUIController>();
    
                // 게임 시작 --> GameScene에 들어왔을 때만 실행
                StartGame();
            }
    
            // Canvas는 Main과 Game 모두 필요하기 때문에 if 밖에서 찾음
            _canvas = GameObject.FindObjectOfType<Canvas>();
        }
    }

     

    >> AIController.cs

    public static class AIController
    {
        public static (int row, int col)? FindNextMove(GameManager.PlayerType[,] board)
        {
            // 가로, 세로, 대각선 비교
            var result1 = FindTwoMarker(board, GameManager.PlayerType.PlayerB);
            // HasValue : 값이 있는지 체크
            if (result1.HasValue) return result1.Value;
            var result2 = FindTwoMarker(board, GameManager.PlayerType.PlayerA);
            if (result2.HasValue) return result2.Value;
            var result3 = FindEmptyPosition(board, GameManager.PlayerType.PlayerA);
            if (result3.HasValue) return result3.Value;
            var result4 = FindEmptyPosition(board, GameManager.PlayerType.PlayerB);
            if (result4.HasValue) return result4.Value;
            var result5 = FindEmptyPosition(board, GameManager.PlayerType.None);
            if (result5.HasValue) return result5.Value;
            return null;
        }
    
        // 빈 칸을 찾은 뒤, 이미 있는 마커 근처를 반환하도록 
        private static (int row, int col)? FindEmptyPosition(GameManager.PlayerType[,] board,
            GameManager.PlayerType playerType)
        {
            for (var row = 0; row < board.GetLength(0); row++)
            {
                for (var col = 0; col < board.GetLength(1); col++)
                {
                    if (board[row, col] == GameManager.PlayerType.None)
                    {
                        if (playerType == GameManager.PlayerType.None) return (row, col);
                        
                        for (var i = -1; i <= 1; i++)
                        {
                            for (var j = -1; j <= 1; j++)
                            {
                                if (i == 0 && j == 0) continue;
                                if (row + i < 0 || row + i >= board.GetLength(0) ||
                                    col + j < 0 || col + j >= board.GetLength(1)) continue;
                                if (board[row + i, col + j] == playerType) return (row, col);
                            }
                        }
                    }
                }
            }
            return null;
        }
        
        // (int row, int col)'?' --> Nullable : null을 할당 할 수 없는 타입도 null로 할당 할 수 있도록, 사용법 : 변수 타입 뒤에 '?' 붙이기
        private static (int row, int col)? FindTwoMarker(GameManager.PlayerType[,] board, GameManager.PlayerType playerType)
        {
            // 가로로 플레이어 마커가 두 개 이상인지 확인
            for (var row = 0; row < board.GetLength(0); row++)
            {
                if (board[row, 0] == playerType &&
                    board[row, 1] == playerType &&
                    board[row, 2] == GameManager.PlayerType.None)
                {
                    return (row, 2);
                }
                
                if (board[row, 1] == playerType &&
                    board[row, 2] == playerType &&
                    board[row, 0] == GameManager.PlayerType.None)
                {
                    return (row, 0);
                }
    
                if (board[row, 0] == playerType &&
                    board[row, 2] == playerType &&
                    board[row, 1] == GameManager.PlayerType.None)
                {
                    return (row, 1);
                }
            }
            
            // 세로로 플레이어 마커가 두 개 이상인지 확인
            for (var col = 0; col < board.GetLength(1); col++)
            {
                if (board[0, col] == playerType &&
                    board[1, col] == playerType &&
                    board[2, col] == GameManager.PlayerType.None)
                {
                    return (2, col);
                }
                
                if (board[1, col] == playerType &&
                    board[2, col] == playerType &&
                    board[0, col] == GameManager.PlayerType.None)
                {
                    return (0, col);
                }
                
                if (board[0, col] == playerType &&
                    board[2, col] == playerType &&
                    board[1, col] == GameManager.PlayerType.None)
                {
                    return (1, col);
                }
            }
            
            // 대각선에 대한 체크
            if (board[0, 0] == playerType &&
                board[1, 1] == playerType &&
                board[2, 2] == GameManager.PlayerType.None)
            {
                return (2, 2);
            }
    
            if (board[1, 1] == playerType &&
                board[2, 2] == playerType &&
                board[0, 0] == GameManager.PlayerType.None)
            {
                return (0, 0);
            }
    
            if (board[0, 0] == playerType &&
                board[2, 2] == playerType &&
                board[1, 1] == GameManager.PlayerType.None)
            {
                return (1, 1);
            }
            
            if (board[0, 2] == playerType &&
                board[1, 1] == playerType &&
                board[2, 0] == GameManager.PlayerType.None)
            {
                return (2, 0);
            }
            
            if (board[1, 1] == playerType &&
                board[2, 0] == playerType &&
                board[0, 2] == GameManager.PlayerType.None)
            {
                return (0, 2);
            }
            
            if (board[0, 2] == playerType &&
                board[2, 0] == playerType &&
                board[1, 1] == GameManager.PlayerType.None)
            {
                return (1, 1);
            }
            
            return null; // 이것을 위해 Nullable 사용
        }
    
        #region MyAlgorithm
        
        private static (int row, int col)? MyFindTwoMarker(GameManager.PlayerType[,] board)
        {
            // 1. 중앙이 비었으면 무조건 중앙을 체크
            if (board[1, 1] == GameManager.PlayerType.None) { return (1, 1); }
            
            // 2. 블록이 두 칸이 이어져있고 남은 한 칸이 비어있으면 체크 --> Player와 AI는 상관 없지만 None은 피해야 함
            // 가로 체크 (None을 피하는 더 아름다운 방법이 없을까..?)
            for (var row = 0; row < board.GetLength(0); row++)
            {
                if (board[row, 0] == board[row, 1] && board[row, 2] == GameManager.PlayerType.None
                                                   && board[row, 0] != GameManager.PlayerType.None)
                {
                    return (row, 2);
                }
                else if (board[row, 1] == board[row, 2] && board[row, 0] == GameManager.PlayerType.None 
                                                        && board[row, 1] != GameManager.PlayerType.None)
                {
                    return (row, 0);
                }
                else if (board[row, 0] == board[row, 2] && board[row, 1] == GameManager.PlayerType.None
                                                        && board[row, 0] != GameManager.PlayerType.None)
                {
                    return (row, 1);
                }
            }
            
            // 세로 체크
            for (var col = 0; col < board.GetLength(1); col++)
            {
                if (board[0, col] == board[1, col] && board[2, col] == GameManager.PlayerType.None 
                                                   && board[0, col] != GameManager.PlayerType.None)
                {
                    return (2, col);
                }
                else if (board[1, col] == board[2, col] && board[0, col] == GameManager.PlayerType.None
                                                        && board[1, col] != GameManager.PlayerType.None)
                {
                    return (0, col);
                }
                else if (board[0, col] == board[2, col] && board[1, col] == GameManager.PlayerType.None
                                                        && board[0, col] != GameManager.PlayerType.None)
                {
                    return (1, col);
                }
            }
            
            // 대각선 체크
            if (board[0, 0] == board[1, 1] && board[2, 2] == GameManager.PlayerType.None)
            {
                return (2, 2);
            }
            else if (board[1, 1] == board[2, 2] && board[0, 0] == GameManager.PlayerType.None)
            {
                return (0, 0);
            }
            
            if (board[0, 2] == board[1, 1] && board[2, 0] == GameManager.PlayerType.None)
            {
                return (2, 0);
            }
            else if (board[1, 1] == board[2, 0] && board[0, 2] == GameManager.PlayerType.None)
            {
                return (0, 2);
            }
            
            // 3. 앞선 두 경우가 아니라면 남은 칸 중에 랜덤으로 체크 --> 일단 지금은 랜덤 없이 테스트
            for (var row = 0; row < board.GetLength(1); row++)
            {
                for (var col = 0; col < board.GetLength(0); col++)
                {
                    if (board[row, col] == GameManager.PlayerType.None)
                    {
                        return (row, col);
                    }
                }
            }
            
            return null; // 이것을 위해 Nullable 사용
        }
    
        #endregion
    }

     

    >> MinimaxAIController.cs

    using System;
    using System.Collections;
    using System.Collections.Generic;
    using UnityEngine;
    
    public static class MinimaxAIController
    {
        public static void printBoard(GameManager.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(GameManager.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] == GameManager.PlayerType.None)
                    {
                        board[row, col] = GameManager.PlayerType.PlayerB;
                        var score = DoMinimax(board, 0, false);
                        board[row, col] = GameManager.PlayerType.None;
    
                        if (score > bestScore)
                        {
                            bestScore = score;
                            bestMove = (row, col);
                        }
                    }
                }
            }
            return bestMove;
        }
        
        private static float DoMinimax(GameManager.PlayerType[,] board, int depth, bool isMaximizing)
        {
            if (CheckGameWin(GameManager.PlayerType.PlayerA, board))
                return -10 + depth;
            if (CheckGameWin(GameManager.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] == GameManager.PlayerType.None)
                        {
                            board[row, col] = GameManager.PlayerType.PlayerB;
                            var score = DoMinimax(board, depth + 1, false); // 재귀함수
                            board[row, col] = GameManager.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] == GameManager.PlayerType.None)
                        {
                            board[row, col] = GameManager.PlayerType.PlayerA;
                            var score = DoMinimax(board, depth + 1, true); // 재귀함수
                            board[row, col] = GameManager.PlayerType.None;
                            bestScore = Math.Min(bestScore, score); // Math.Min(a, b) : a와 b 중에 작은 값을 반환
                        }
                    }
                }
                return bestScore;
            }
        }
        
        /// <summary>
        /// 모든 마커가 보드에 배치 되었는지 확인하는 함수
        /// </summary>
        /// <returns>True : 모두 배치</returns>
        public static bool IsAllBlocksPlaced(GameManager.PlayerType[,] board)
        {
            for (var row = 0; row < board.GetLength(0); row++)
            {
                for (var col = 0; col < board.GetLength(1); col++)
                {
                    if (board[row, col] == GameManager.PlayerType.None)
                        return false;
                }
            }
            return true;
        }
        
        /// <summary>
        /// 게임의 승패를 판단하는 함수
        /// </summary>
        /// <param name="playerType"></param>
        /// <param name="board"></param>
        /// <returns></returns>
        private static bool CheckGameWin(GameManager.PlayerType playerType, GameManager.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;
        }
    }