Carrot
본문 바로가기
Unity/멋쟁이사자처럼 부트캠프

[멋쟁이사자처럼부트캠프] 유니티 게임 개발 5기(87일차) - Omok AI

by 독기품은토끼 2025. 9. 23.
✅ 오늘의 학습 목표
1. Omok AI만들기
- MiniMax + 알파베타 가지치기로 오목의 최적 수 탐색
- 평가함수 활용

1. Omok AI 만들기

오늘부터 다시 수업 진도를 나가게 되었다.

기존에 틱택토 AI는 구현되어 있었는데 이걸 Omok AI로 응용하는 시간을 가졌다.

 

1. 오목판 만들기

public class BlockController : MonoBehaviour
{
    public void InitBlocks()
    {
        float colStartPos = -12.6f;
        float rowStartPos = 12.6f;

        blocks = new Block[15 * 15];

        for (int i = 0; i < 15; i++)
        {
            for (int j = 0; j < 15; j++)
            {
                var blockObject = Instantiate(blockPrefab, transform);
                blockObject.transform.localPosition =
                    new Vector3(colStartPos + (1.8f * j), rowStartPos - (1.8f * i), 0);

                var blockIndex = i * 15 + j;

                Block block = blockObject.GetComponent<Block>();
                block.InitMarker(blockIndex, blockIndex =>
                {
                    // 특정 Block이 클릭 된 상태에 대한 처리
                    var row = blockIndex / Constants.BlockColumnCount;
                    var col = blockIndex % Constants.BlockColumnCount;
                    OnBlockClickedDelegate?.Invoke(row, col);
                });
                blocks[i * 15 + j] = block;
            }
        }
    }
}
public static class Constants
{
    public const int BlockColumnCount = 15;
}

 

기존에는 오목 프리팹을 UI에 9개 생성해주었지만

이번에는 15 x 15 사이즈의 오목판을 만들어줄 것이기 때문에 스크립트를 활용해 주었다.

 

2. AI

using System;
using System.Threading.Tasks;
using UnityEngine;

public static class OmokAI
{
    private const int MAX_DEPTH = 2;
    
    private const float MAX_SCORE = 100000;
    private const float OPEN_FOUR = 50000;
    private const float CLOSED_FOUR = 10000;
    private const float OPEN_THREE = 5000;
    private const float CLOSED_THREE = 1000;
    private const float OPEN_TWO = 100;
    private const float CLOSED_TWO = 10;
    
    // 방어 가중치
    private const float DEFENSE_MULTIPLIER = 2f;
    
    // 현재 상태를 전달하면 다음 최적의 수를 반환하는 메서드
    public static async Task<(int row, int col)?> GetBestMove(Constants.PlayerType[,] board)
    {
        return await Task.Run(() =>
        {
            float bestScore = -MAX_SCORE;
            (int row, int col)? movePosition = null;
        
            float alpha = -MAX_SCORE;
            float beta = MAX_SCORE;

            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, alpha, beta);
                        // PrintBoard(board);
                        board[row, col] = Constants.PlayerType.None;
                    
                        // 가장 높은 점수와 그 점수의 row, col 정보를 저장한다/
                        if (score > bestScore)
                        {
                            bestScore = score;
                            movePosition = (row, col);
                        }
                    
                        // Max 에서는 기존 alpha와 bestScore 사이에 높은 값을 alpha에 저장한다
                        alpha = Math.Max(alpha, bestScore);
                    }
                }
            }

            return movePosition;
        });
    }

    private static float DoMiniMax(Constants.PlayerType[,] board, int depth, bool isMaximizing, float alpha, float beta)
    {
        // 게임 종료 상태 체크
        if (CheckGameWin(Constants.PlayerType.PlayerA, board))
            return -MAX_SCORE;
        if (CheckGameWin(Constants.PlayerType.PlayerB, board))
            return MAX_SCORE;
        if (CheckGameDraw(board))
            return 0;
        if (depth >= MAX_DEPTH)
            return EvaluateBoard(board);
        
        Debug.Log("depth : " + depth);

        if (isMaximizing)
        {
            var bestScore = -MAX_SCORE;
            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, alpha, beta);
                        // PrintBoard(board);
                        board[row, col] = Constants.PlayerType.None;
                        bestScore = Math.Max(score, bestScore);
                        alpha = Math.Max(alpha, bestScore);
                        
                        if (beta <= alpha) break;
                    }
                }
                if (beta <= alpha) break;
            }
            return bestScore;
        }
        else
        {
            var bestScore = MAX_SCORE;
            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, alpha, beta);
                        // PrintBoard(board);
                        board[row, col] = Constants.PlayerType.None;
                        bestScore = Math.Min(score, bestScore);
                        beta = Math.Min(beta, bestScore);
                        
                        if (beta <= alpha) break;
                    }
                }
                if (beta <= alpha) break;
            }
            return bestScore;
        }
    }
    
    // 비겼는지 확인
    public static bool CheckGameDraw(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>
    /// 15x15 오목판에서 승리한 플레이어를 확인하는 함수
    /// </summary>
    /// <param name="playerType">확인할 플레이어 타입</param>
    /// <param name="board">현재 게임 보드</param>
    /// <returns>해당 플레이어가 승리했으면 true, 아니면 false</returns>
    public static bool CheckGameWin(Constants.PlayerType playerType, Constants.PlayerType[,] board)
    {
        // None 타입은 승리 조건에서 제외
        if (playerType == Constants.PlayerType.None)
            return false;

        // 가로 방향 확인
        for (var row = 0; row < board.GetLength(0); row++)
        {
            for (var col = 0; col <= board.GetLength(1) - 5; col++)
            {
                bool win = true;
                for (var i = 0; i < 5; i++)
                {
                    if (board[row, col + i] != playerType)
                    {
                        win = false;
                        break;
                    }
                }
                if (win) 
                {
                    return true;
                }
            }
        }

        // 세로 방향 확인
        for (var row = 0; row <= board.GetLength(0) - 5; row++)
        {
            for (var col = 0; col < board.GetLength(1); col++)
            {
                bool win = true;
                for (var i = 0; i < 5; i++)
                {
                    if (board[row + i, col] != playerType)
                    {
                        win = false;
                        break;
                    }
                }
                if (win) 
                {
                    return true;
                }
            }
        }

        // 대각선 방향 (좌상단 -> 우하단) 확인
        for (var row = 0; row <= board.GetLength(0) - 5; row++)
        {
            for (var col = 0; col <= board.GetLength(1) - 5; col++)
            {
                bool win = true;
                for (var i = 0; i < 5; i++)
                {
                    if (board[row + i, col + i] != playerType)
                    {
                        win = false;
                        break;
                    }
                }
                if (win) 
                {
                    return true;
                }
            }
        }

        // 대각선 방향 (우상단 -> 좌하단) 확인
        for (var row = 0; row <= board.GetLength(0) - 5; row++)
        {
            for (var col = 4; col < board.GetLength(1); col++)
            {
                bool win = true;
                for (var i = 0; i < 5; i++)
                {
                    if (board[row + i, col - i] != playerType)
                    {
                        win = false;
                        break;
                    }
                }
                if (win) 
                {
                    return true;
                }
            }
        }

        return false;
    }
    
    private static float EvaluateBoard(Constants.PlayerType[,] board)
    {
        float score = 0;
        int rows = board.GetLength(0);
        int cols = board.GetLength(1);

        // 각 방향에 대한 패턴 평가
        for (int row = 0; row < rows; row++)
        {
            for (int col = 0; col < cols; col++)
            {
                if (board[row, col] == Constants.PlayerType.None)
                    continue;

                bool isAI = board[row, col] == Constants.PlayerType.PlayerB;
                float multiplier = isAI ? 1 : -DEFENSE_MULTIPLIER;

                // 가로 방향
                if (col <= cols - 5)
                {
                    var pattern = EvaluatePattern(board, row, col, 0, 1, 5);
                    score += pattern * multiplier;
                }

                // 세로 방향
                if (row <= rows - 5)
                {
                    var pattern = EvaluatePattern(board, row, col, 1, 0, 5);
                    score += pattern * multiplier;
                }

                // 대각선 방향 (우하향)
                if (row <= rows - 5 && col <= cols - 5)
                {
                    var pattern = EvaluatePattern(board, row, col, 1, 1, 5);
                    score += pattern * multiplier;
                }

                // 대각선 방향 (우상향)
                if (row >= 4 && col <= cols - 5)
                {
                    var pattern = EvaluatePattern(board, row, col, -1, 1, 5);
                    score += pattern * multiplier;
                }
            }
        }

        return score;
    }

    private static float EvaluatePattern(Constants.PlayerType[,] board, int startRow, int startCol, int dRow, int dCol, int length)
    {
        var currentPlayer = board[startRow, startCol];
        int count = 1;
        int emptyBefore = 0;
        int emptyAfter = 0;
        bool blocked = false;

        // 연속된 돌 확인
        for (int i = 1; i < length; i++)
        {
            int newRow = startRow + dRow * i;
            int newCol = startCol + dCol * i;

            if (!IsValidPosition(newRow, newCol, board))
            {
                blocked = true;
                break;
            }

            if (board[newRow, newCol] == currentPlayer)
            {
                count++;
            }
            else if (board[newRow, newCol] == Constants.PlayerType.None)
            {
                emptyAfter++;
                break;
            }
            else
            {
                blocked = true;
                break;
            }
        }

        // 반대 방향 빈 공간 확인
        for (int i = 1; i < length; i++)
        {
            int newRow = startRow - dRow * i;
            int newCol = startCol - dCol * i;

            if (!IsValidPosition(newRow, newCol, board))
            {
                blocked = true;
                break;
            }

            if (board[newRow, newCol] == Constants.PlayerType.None)
            {
                emptyBefore++;
                break;
            }
            else if (board[newRow, newCol] != currentPlayer)
            {
                blocked = true;
                break;
            }
        }

        // 패턴 점수 계산
        if (count >= 5) return MAX_SCORE;
        
        bool isOpen = emptyBefore > 0 && emptyAfter > 0;
        
        switch (count)
        {
            case 4:
                return isOpen ? OPEN_FOUR : (blocked ? CLOSED_FOUR / 2 : CLOSED_FOUR);
            case 3:
                return isOpen ? OPEN_THREE : (blocked ? CLOSED_THREE / 2 : CLOSED_THREE);
            case 2:
                return isOpen ? OPEN_TWO : (blocked ? CLOSED_TWO / 2 : CLOSED_TWO);
            default:
                return 0;
        }
    }

    private static bool IsValidPosition(int row, int col, Constants.PlayerType[,] board)
    {
        // row가 0보다 크거나 같고, row가 전체 row 길이보다 작고, col이 0보다 크거나 같고, col이 전체 col 길이보다 작다면
        return row >= 0 && row < board.GetLength(0) && col >= 0 && col < board.GetLength(1);
    }
    
    private static bool IsMinimaxValidPosition(int row, int col, Constants.PlayerType[,] board)
    {
        // row가 0보다 크고, col이 0보다 크고, board[row-1, col-1]이 None 이니거나 [왼쪽 상단]
        // row가 0보다 크고, board[row-1, col]이 None이 아니거나 [윗쪽]
        // row가 0보다 크고, col이 전체 col 길이 보다 1 작고, [오른쪽 상단]
        // [왼쪽]
        // [오른쪽]
        // [왼쪽 하단]
        // [아랫쪽]
        // [우측 하단]
        
        if ((row > 0 && col > 0 && board[row - 1, col - 1] != Constants.PlayerType.None) ||
            (row > 0 && board[row - 1, col] != Constants.PlayerType.None) ||
            (row > 0 && col < board.GetLength(1) - 1 && board[row - 1, col + 1] != Constants.PlayerType.None) ||
            (col > 0 && board[row, col - 1] != Constants.PlayerType.None) ||
            (col < board.GetLength(1) - 1 && board[row, col + 1] != Constants.PlayerType.None) ||
            (row < board.GetLength(0) - 1 && col > 0 && board[row + 1, col - 1] != Constants.PlayerType.None) ||
            (row < board.GetLength(0) - 1 && board[row + 1, col] != Constants.PlayerType.None) ||
            (row < board.GetLength(0) - 1 && col < board.GetLength(1) - 1 && board[row + 1, col + 1] != Constants.PlayerType.None))
        {
            return true;
        }
        return false;
    }
    
    public static void PrintBoard(Constants.PlayerType[,] board)
    {
        int rows = board.GetLength(0);
        int cols = board.GetLength(1);

        string output = "\n";
        for (int row = 0; row < rows; row++)
        {
            for (int col = 0; col < cols; col++)
            {
                switch (board[row, col])
                {
                    case Constants.PlayerType.PlayerA:
                        output += "[o]";
                        break;
                    case Constants.PlayerType.PlayerB:
                        output += "[x]";
                        break;
                    default:
                        output += "[ ]";
                        break;
                }
            }
            output += "\n";
        }
        Debug.Log(output);
    }
}

 

[최적수 탐색 -> GetBestMove()]

보드의 모든 빈 칸을 후보로 가정하고

그 자리에 돌을 임시로 둔 뒤 MiniMax 점수를 계산해 가장 높은 점수의 좌표를 반환한다.

결과를 반환한 후 임시로 둔 돌은 원상복귀된다.

 

[미니맥스 + 알파베타 -> DoMinMax]

  • 터미널 체크
    • A가 이겼으면 -MAX_SCORE (AI 입장에선 최악)
    • B가 이겼으면 +MAX_SCORE
    • 무승부면 0
    • 최대 깊이에 도달하면 EvaluateBoard 호출
  • Max/Min 단계
    • Max는 B의 수를, Min은 A의 수를 모든 빈 칸에 시뮬레이션한다.
    • 각 단계에서 alpha/beta를 갱신하고, beta <= alpha면 즉시 가지치기로 루프를 탈출한다.

[승패 판정 -> CheckGameWin / CheckGameDraw]

 

가로 / 세로 / 두 대각선 4방향으로 연속 5개를 검사하여 승패를 판정한다.

GameLogic.CheckGameResult() 에서도 동일한 메서드를 호출하여 UI/씬 전환 흐름과 일관성을 맞추어 주었다.

 

[평가 함수]

이 코드의 핵심은 패턴 가중치에 있다.

각 칸에서 시작해 4방향으로 최대 5길이 패턴을 조사하고 열림/닫힘 여부로 점수를 다르게 준다.

  • 가중치(예):
    • OPEN_FOUR = 50000 (열린 4)
    • CLOSED_FOUR = 10000 (닫힌 4)
    • OPEN_THREE = 5000, CLOSED_THREE = 1000
    • OPEN_TWO = 100, CLOSED_TWO = 10
  • 열린(Open) 기준 : 양 끝이 모두 빈 칸(두 방향이 확장 가능) → 훨씬 위험/유리하므로 점수 큼
  • 방어 가중치 : 상대 돌(A) 패턴에는 -DEFENSE_MULTIPLIER(=2)를 곱해 막아야 할 위협을 더 크게 본다.
  • 패턴 카운팅 로직:
    • 현재 돌 기준으로 같은 돌이 연속 몇 개인지(count) 세고, 앞/뒤 비어있는지(emptyBefore, emptyAfter) 확인
    • count>=5면 즉시 MAX_SCORE 처리
    • count=4/3/2에 대해 Open/Closed 판별로 서로 다른 점수 반환

 

public class AIState : BasePlayerState
{
    public override async void OnEnter(GameLogic gameLogic)
    {
        // 턴 표시
        GameManager.Instance.SetGameTurnPanel(GameUIController.GameTurnPanelType.BTurn);

        // 보드 상태 갱신
        var board = gameLogic.GetBoard();

        // AI 연산
        // var result = TicTacToeAI.GetBestMove(board);
        var result = await OmokAI.GetBestMove(board);

        if (result.HasValue)
        {
            HandleMove(gameLogic, result.Value.row, result.Value.col);
        }
        else
        {
            gameLogic.EndGame(GameLogic.GameResult.Draw);
        }
    }
}
public class GameLogic : IDisposable
{
    // 게임의 결과 확인
    public GameResult CheckGameResult()
    {
        if (OmokAI.CheckGameWin(Constants.PlayerType.PlayerA, _board)) { return GameResult.Win; }
        if (OmokAI.CheckGameWin(Constants.PlayerType.PlayerB, _board)) { return GameResult.Lose; }
        if (OmokAI.CheckGameDraw(_board)) { return GameResult.Draw; }
        return GameResult.None;
    }
}

 

기존 틱택토 AI 로직으로 이동되는 것을 OmokAI로 이동되도록 AI State, GameLogic 스크립트를 수정해주었다.