✅ 오늘의 학습 목표
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 스크립트를 수정해주었다.
