TIL

[멋쟁이사자처럼 부트캠프 TIL] 70일차 : 틱택토 자동로직 완성

Cadi 2025. 2. 7. 23:28

오늘 배운 것

1. 알아두면 좋은 것

2. 틱택토 알고리즘 (노가다성)

3. minMax를 사용한 알고리즘

 

 

 

1. 알아두면 좋은 것

 

1. 변수에 null 할당

 

null을 할당할 수 있는 타입이 있고, 그렇지 않은 타입이 있다. 

 

  • Null 할당 가능 : 참조 타입(Reference Type), Nullable 값 타입(Nullable Value type)
  • Null 할당 불가 : 값 타입(Value Type)

 

기본적으로 int, float, bool , enum 등의 값 타입은 null을 할당할 수 없지만 뒤에 ?를 붙여 Nullable Valeu Type으로

변환 가능하다.

 

예를 들어 

int age = 26; // null 할당 불가
int? age = 26; // null 할당 가능

 

이런 식으로 변환해서 사용할 수 있다.

 

튜플도 마찬가지이다.  오늘 사용한 코드에서 나온 것처럼 반환값 

public static (int row, int col)? GetBestMove(GameManager.PlayerType[,] board)

(int row, int col)? 로 선언하는 방식으로도 사용할 수 있다. 

 

그러나 지금은 int형을 Nullable 타입으로 바꾼 것이라면 4byte의 인트형을 Null 박스로 감싼 것이기 때문에

언박싱하는 기능이 필요하다.

그래서 위의 예시에서 26이라는 숫자를 가져오려면

int result = age.Value;

위와 같은 방식으로 가져와야 한다.

 

또한 값이 Null이 들어있는지 확인하는 함수는 다음과 같다.

age.HasValue; // 값이 null이 아니면 True, null이면 False를 반환

 

GetValueORDefault() 메서드를 사용해서 타입이 null일 경우 지정된 기본값을 반환하고, 값이 있을 경우 해당 값을

반환받을 수도 있다. 

int? age = null;
int actualAge = age.GetValueOrDefault(0); // null이면 0을 반환

Console.WriteLine($"나이: {actualAge}"); // 출력: 나이: 0

 

null 병합 연산자도 있다 . '??'로 사용하는데 Nullable 타입이 null일 경우 지정된 값을 반환하고,

  값이 있을 경우 해당 값을 반환한다.

 

int? age = null;

int actualAge = age ?? 0; // age가 null이면 0을 반환

 

 

어제 코테 풀면서 다른 분의 코드 예시가 이해가 가지 않았었는데 이 null 병합 연산자 때문이었다. 

다시 배웠으니 이제 좀 더 편하게 읽히겠지 ! 

 

2. ML 에이전트 (ML - Agents)

 

유니티 엔진에서 머신러닝을 활용하여 게임 캐릭터나 AI를 훈련시키고 제어할 수 있도록 도와주는 툴킷

강화학습, 모방 학습, 다양한 알고리즘 등을 지원함.

 

 

2. 틱택토 알고리즘 (간단한)

 

어제 내가 만들었던 것은 이미 만들어져 있던 함수를 사용해서 만들었던 것이다.

완전 직관적으로 새로운 로직을 만들어 주셨다. 그냥 모든 다음 수를 검사하는 방법이다.

 

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);
        }
    }
    

 

우선 이런 식으로 2개가 줄이 한 줄(빙고가 되는)에 있고, 나머지가 비어 있는 곳을 찾는 함수이다.

길어서 다 적지는 못하였지만 이렇게도 하고, 세로도 찾고 ,대각선도 찾는다. 

이렇게 가로, 세로, 대각선을 다 검사하고 찾지 못하면 Null을 반환한다. 

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;
}

 

모든 칸을 순회하며 빈 칸을 찾고, 만일 비어있다면, 그 주변 칸을 순회하며 목표로 하는 playerType이 있으면 그 곳에 돌을 둔다. 

0,0 은 자기 자신이니까 제외하고, -1 ~ 1 까지라는 조건의 특성상 없는 칸에서 playerType을 찾을 수 있기 때문에 이를 예외처리로 빼 준다. 

 

그리고 이런 식으로 하나하나 검사해 주면 된다.

public static (int row, int col)? FindNextMove(GameManager.PlayerType[,] board)
{
    // 가로, 세로, 대각선 비교
    var result1 = FindTwoMarker(board, GameManager.PlayerType.PlayerB);
    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;
}

 

1. 자신(PlayerB / AI) 가 놓으면 끝나는 지점 검사

2. 상대(PalyerA)가 놓으면 끝나는 지점 검사

3. 상대방의 돌이 있는 지점 주변 검사

4. 내 돌이 있는 지점 주변 검사

5. 혹시 모르는 예외처리. 

 

AI는 정상 작동하나, 아직은 이길 수 있는 AI이다. 

 

 

3. MinMax를 활용한 틱택토 알고리즘

 

MinMax의 골자는, 경우의 수를 모두 탐색하며 각각 점수를 매기고, 그 점수에 따라 행동을 결정하는 것이다. 

예를 들어 지금 틱택토의 경우, AI의 입장에서 좋은 수는 AI가 이기며 게임을 끝내는 수이고, 나쁜 수는 Player가 이기며 게임을 끝내는 착수이다. 그리고 그 중간에는 비기는 결과도 있다. 

 

모든 칸에 순서대로 착수하며 게임이 끝났는지 계속되는지를 검사하고, AI가 이겼다면 높은 점수를, 사람이 이겼다면 낮은 점수를 ,비겼다면 그 중간의 점수를 할당한다. 그리고 그 결과를 Player의 입장에서, 혹은 AI의 입장에서 한 칸씩 올리며 최종 착수 지점을 찾는 방식이다. 

 

우리는 플레이어가 이기기 위한 최선의 수를 착수할 것이라 가정한다. 그렇기 때문에 플레이어의 턴일 때 플레이어는 점수가 가장 낮은 착수 지점을 선택할 것이라 가정하고 알고리즘을 풀어나간다. 

 

이는 재귀함수의 형태로 구현된다. 재귀 함수는 자신을 호출하는 형태로 기본적인 재귀  함수의 형태는 다음과 같다. 

 

private static void Main()
{
    CountNumbers(5);
    
    int CountNumbers(int n)
    {
        if (n == 0) return 0;
        else
        {
            Console.WriteLine(n);
            return CountNumbers(n - 1);
        }
    }
}

 

이런 결과가 나오게 된다. 

 

CountNumbers(5)는 CountNumbers(4)를 호출하고 이는 또 CountNumbers(3) , CountNumbers(2) ,CountNumbers(1)

결국 CountNumbers(0)이 호출된다. 그렇게 되면 0이 리턴받고 실행 되었던 CountNumbers(1) ~ CountNumbers(5)가

순차적으로 중단되게 된다. 

 

우선, 저번에 만들어 두었던 게임이 종료되는 상태를 체크하는 함수를 가져다 쓴다. 

모든 칸에 블럭이 놓였는지를 체크하는 IsAllBlocksPlaced() 함수

/// <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;
}

 

어떤 플레이어가 승리했는지를 체크하는 CheckGameWin()

/// <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;
}

 

 

이 함수들을 매 경우의 수 (착수되는 경우의 수)마다 호출하고 이 결과에 따라 점수를 부여한다. 

 

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 = MathF.Max(bestScore, score);
                }
            }
        }

        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 = MathF.Min(bestScore, score);
                }
            }
        }

        return bestScore;
    }
}

 

순서대로 설명을 하자면 

 if (CheckGameWin(GameManager.PlayerType.PlayerA, board))
        return -10 + depth;
    if (CheckGameWin(GameManager.PlayerType.PlayerB, board))
        return 10 - depth;
    if (IsAllBlocksPlaced(board))
        return 0;

 

이렇게 매 호출마다 일단 게임이 종료되었는지를 확인하고 종료되었으면 점수를 반환한다.

이때 점수는 결과값과 depth에 따라 달라진다. depth는 DoMinMax가 반복된 횟수 즉 트리 구조의 하위 계층일수록 Depth가 높다. AI의 입장에서 플레이어가 늦게 이길수록 좋으니 플레이어가 이기는 경우의 수에는 -10 + depth를,

AI가 이기는 경우는 빨리 이기는 것이 좋으니 10 - depth를 한다. 

 

  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 = MathF.Max(bestScore, score);
                }
            }
        }

        return bestScore;
    }

 

이 부분은 isMaximizing이면 작동되는 코드다. isMaximizing은 매개변수로 받는 것으로 플레이어의 턴이면 플레이어가

이기는 방식으로 행동할 것이기 때문에 false로 받고, AI의 턴인 경우에는 점수를 최대하 하는 방식으로 작동해야하기 때문에 isMaximainzing이 true가 도니다. 

 

비교를 위해 bestScore을 설정하고 배열을 순회하며 None인 곳에 플레이어 타입을 B로 놓고 또 DoMinimax를 실행하고 

나온 점수를 score에 반영한다. 그리고 다시 배열을 None으로 돌려준다. ( 어제 코테 틀렸던걸 기억하자)

그리고 점수를 비교해서 bestScore에 저장한다. 지금은 플레이어 B 턴이기 때문에 MathF.Max로 찾는다. 

 

그리고 모든 경우의 수를 다 찾아보고 bestSocre을 반환한다. 

 

 

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 = MathF.Min(bestScore, score);
            }
        }
    }

    return bestScore;
}

 

반대로 플레이어의 턴이라고 가정했을때 실행될 코드이다. 

 

 

public static (int row, int col)? GetBestMove(GameManager.PlayerType[,] board)
{
    float bestScore = float.MinValue;
    (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;
}

 

위의 함수들을 사용해 AI의 다음 슬롯을 계산할 함수이다. 

 

비교할 bestScore을 설정하고

기본 bestMove를 null로 둔다.

 

그리고 모든 칸에 하나씩 플레이어 B 타입을 두며 

DoMinMax를 실행한다. 

그리고 나온 스코어 (첫 턴에 8개) 중에서 가장 높은 스코어가 있는 것을 선택해서 그 결과를 저장해두고 그 결과를 리턴한다. 

 

 

 


이제 코드를 이해하는 것은 쉬운데, 이걸 생각해내지는 못한다 아직. 

더 많은 코드와 알고리즘을 접하고 언젠간 딱 봤을때 이런 식으로 만들 수 있는 사람이 되어야지. 

 

다른 분과 하나씩 코딩 테스트를 같이 풀고 결과를 공유하기로 했다.  

주말에도 조금씩 힘내 봐야지 .