목차
틱택토 게임 만들기
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 값을 가져옴
>> 재귀함수
※ 재귀함수 테스트 코드
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); // 콘솔 창에 보드 출력
}
※ 알고리즘이 동작하는 것을 눈으로 보기 좋은 무료 예제
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;
}
}
'Development > C#' 카테고리의 다른 글
멋쟁이사자처럼부트캠프 Unity 게임 개발 3기 52일차 (0) | 2025.02.11 |
---|---|
멋쟁이사자처럼부트캠프 Unity 게임 개발 3기 51일차 (0) | 2025.02.10 |
멋쟁이사자처럼부트캠프 Unity 게임 개발 3기 49일차 (1) | 2025.02.06 |
멋쟁이사자처럼부트캠프 Unity 게임 개발 3기 48일차 (0) | 2025.02.05 |
멋쟁이사자처럼부트캠프 Unity 게임 개발 3기 47일차 (2) | 2025.02.04 |