✅ 오늘의 학습 목표
1. 모바일 틱택토 게임 구현 (2)
1. 게임 규칙
public class Constants
{
public enum GameType { SinglePlay, DualPlay, MultiPlay }
public enum PlayerType { None, PlayerA, PlayerB }
public const int BlockColumnCount = 3;
}
게임 전체에서 공통으로 쓰는 상수와 열거형을 한곳에 모아 규칙의 기준점을 만들어 주었다.
보드 크기, 게임 타입, 플레이어 타입 같은 값이 여기서 표준화되면 이후 로직이 단순해진다.
- GameType에 SinglePlay, DualPlay, MultiPlay를 정의해 모드를 구분한다.
- 턴 주체를 나타내는 PlayerType을 None, PlayerA, PlayerB로 둔다.
- 3×3 보드를 위한 BlockColumnCount = 3를 상수로 둔다.
보드 좌표 계산과 승패 판정은 보드 크기에 강하게 의존하므로, BlockColumnCount 하나만 바꾸면 모든 계산이 자동으로 따라가게 만드는 구조가 핵심이다.
2. 보드 한 칸의 역할
보드의 한 칸을 책임지는 뷰-컴포넌트를 만들어 줄 것이다.
마커(O/X)를 시각화하고 유저의 클릭을 상위 컨트롤러로 전달한다.
using System;
using UnityEngine;
using UnityEngine.EventSystems;
[RequireComponent(typeof(SpriteRenderer))]
public class Block : MonoBehaviour
{
[SerializeField] private Sprite oSprite;
[SerializeField] private Sprite xSprite;
[SerializeField] private SpriteRenderer markerSpriteRenderer;
public delegate void OnBlockClicked(int index);
private OnBlockClicked _onBlockClicked;
// 마커 타입
public enum MarkerType { None, O, X }
// Block Index
private int _blockIndex;
// Block의 색상 변경을 위한 Block의 Sprite Renderer
private SpriteRenderer _spriteRenderer;
private Color _defaultBlockColor;
private void Awake()
{
_spriteRenderer = GetComponent<SpriteRenderer>();
_defaultBlockColor = _spriteRenderer.color;
}
// 1. 초기화
public void InitMarker(int blockIndex, OnBlockClicked onBlockClicked)
{
_blockIndex = blockIndex;
SetMarker(MarkerType.None);
SetBlockColor(_defaultBlockColor);
_onBlockClicked = onBlockClicked;
}
// 2. 마커 설정
public void SetMarker(MarkerType markerType)
{
switch (markerType)
{
case MarkerType.None:
markerSpriteRenderer.sprite = null;
break;
case MarkerType.O:
markerSpriteRenderer.sprite = oSprite;
break;
case MarkerType.X:
markerSpriteRenderer.sprite = xSprite;
break;
}
}
// 3. Block 배경 색상 변경
public void SetBlockColor(Color color)
{
_spriteRenderer.color = color;
}
// 4. 블럭 터치 처리
private void OnMouseUpAsButton()
{
if (EventSystem.current.IsPointerOverGameObject())
{
return;
}
Debug.Log("Selected Block: " + _blockIndex);
_onBlockClicked?.Invoke(_blockIndex);
}
}
- O/X 스프라이트와 마커용 SpriteRenderer를 직렬화 필드로 보유한다.
- 블록의 인덱스를 기억하고 초기화 시 마커를 비우고 기본 색으로 리셋한다(InitMarker)
- SetMarker는 None/O/X에 따라 표시 스프라이트를 바꾼다
- SetBlockColor로 배경색을 바꿔 강조나 피드백을 줄 수 있다
- 클릭 처리(OnMouseUpAsButton)에서 UI 위 클릭을 필터링한 뒤 자신이 가진 인덱스를 콜백으로 전달한다
뷰는 오직 표시와 입력 수집만 담당하고 게임 규칙은 알지 못한다.
즉 입력 → 콜백(delegate) → 상위 컨트롤러로 흐르게 하여 표시와 규칙을 분리하는 구조다.
3. 블록 칸 제어
여러 개의 Block을 한 번에 초기화하고
개별 블록의 클릭을 행/열 좌표로 변환해 게임 로직으로 전달해 주겠다.
using UnityEngine;
public class BlockController : MonoBehaviour
{
[SerializeField] private Block[] blocks;
public delegate void OnBlockClicked(int row, int col);
public OnBlockClicked OnBlockClickedDelegate;
// 1. 모든 Block을 초기화
public void InitBlocks()
{
for (int i = 0; i < blocks.Length; i++)
{
blocks[i].InitMarker(i, blockIndex =>
{
// 특정 Block이 클릭 된 상태에 대한 처리
var row = blockIndex / Constants.BlockColumnCount;
var col = blockIndex % Constants.BlockColumnCount;
OnBlockClickedDelegate?.Invoke(row, col);
});
}
}
// 2. 특정 Block에 마커 표시
public void PlaceMaker(Block.MarkerType markerType, int row, int col)
{
// row, col >> index 변환
var blockIndex = row * Constants.BlockColumnCount + col;
blocks[blockIndex].SetMarker(markerType);
}
// 3. 특정 Block의 배경색을 설정
public void SetBlockColor()
{
// TODO: 게임 로직이 완성되면 구현
}
}
- blocks 배열로 9칸을 보유하고 OnBlockClickedDelegate(int row, int col)를 노출한다
- InitBlocks()는 각 블록에 자신만의 인덱스를 주고 클릭 시 index → (row, col) 변환 후 상위로 이벤트를 올린다
- PlaceMaker()는 (row, col)을 다시 1차 인덱스로 바꿔 해당 칸에 O/X를 표시한다
- SetBlockColor()는 추후 로직에 맞춰 배경 강조를 넣도록 비워 두었다
1차원 인덱스를 row = index / 3, col = index % 3로 풀어내는 변환을 컨트롤러가 전담한다.
이렇게 하면 Block은 좌표를 몰라도 되고 게임 로직은 좌표만 알면 되어 관심사가 명확히 분리된다.
4. 턴 관리의 뼈대
턴 기반 게임에서 “현재 누구 차례인가?”를 깔끔하게 다루기 위해 상태 패턴의 공통 골격을 정의한다.
using UnityEngine;
public abstract class BasePlayerState
{
public abstract void OnEnter(GameLogic gameLogic); // 상태 시작
public abstract void OnExit(GameLogic gameLogic); // 상태 종료
public abstract void HandleMove(GameLogic gameLogic, int row, int col); // 마커 표시
protected abstract void HandleNextTurn(GameLogic gameLogic); // 턴 전환
// 게임 결과 처리
protected void ProcessMove(GameLogic gameLogic, Constants.PlayerType playerType,
int row, int col)
{
if (gameLogic.SetNewBoardValue(playerType, row, col))
{
// 새롭게 놓여진 Marker를 기반으로 게임의 결과를 판단
var gameResult = gameLogic.CheckGameResult();
if (gameResult == GameLogic.GameResult.None)
{
HandleNextTurn(gameLogic);
}
else
{
gameLogic.EndGame(gameResult);
}
}
}
}
- OnEnter, OnExit, HandleMove, HandleNextTurn을 추상 메서드로 강제한다
- ProcessMove()는 한 수를 두는 공통 루틴이다
- 보드 업데이트 → 결과 판정 → 계속/종료 분기
수행 로직(수 두기, 판정)은 공통부로 두고
다음 턴이 누구로 가는지만 파생 클래스가 결정하게 한다.
이렇게 하면 싱글/멀티/듀얼 같은 변형도 동일한 틀에 꽂아 넣을 수 있다.
5. 플레이어 턴 상태 구현
“플레이어 A의 차례”, “플레이어 B의 차례”를 각각 독립 상태로 만들고 차례일 때만 입력을 받도록 상태를 구현해주었다.
public class PlayerState : BasePlayerState
{
private bool _isFirstPlayer;
private Constants.PlayerType _playerType;
public PlayerState(bool isFirstPlayer)
{
_isFirstPlayer = isFirstPlayer;
_playerType = _isFirstPlayer ?
Constants.PlayerType.PlayerA : Constants.PlayerType.PlayerB;
}
#region 필수 메서드
public override void OnEnter(GameLogic gameLogic)
{
// 1. First Player인지 확인해서 게임 UI에 현재 턴 표시
// TODO: Game 씬에 턴 표시 UI 구현 후 진행 예정
// 2. Block Controller에게 해야 할 일을 전달
gameLogic.blockController.OnBlockClickedDelegate = (row, col) =>
{
// Block이 터치 될 때까지 기다렸다가 터치 되면 처리할 일
HandleMove(gameLogic, row, col);
};
}
public override void OnExit(GameLogic gameLogic)
{
gameLogic.blockController.OnBlockClickedDelegate = null;
}
public override void HandleMove(GameLogic gameLogic, int row, int col)
{
ProcessMove(gameLogic, _playerType, row, col);
}
protected override void HandleNextTurn(GameLogic gameLogic)
{
if (_isFirstPlayer)
{
gameLogic.SetState(gameLogic.secondPlayerState);
}
else
{
gameLogic.SetState(gameLogic.firstPlayerState);
}
}
#endregion
}
- 생성자에서 isFirstPlayer로 A/B를 정하고 PlayerType을 캐싱한다
- OnEnter()는 블록 클릭 델리게이트를 등록해 이 턴에만 입력을 받는다
- OnExit()는 델리게이트를 해제해 입력을 막는다
- HandleMove()는 ProcessMove()(공통)로 위임한다
- HandleNextTurn()에서 A→B, B→A로 상태를 전환한다
입력의 on/off를 상태 전이와 함께 관리한다.
이렇게 하면 동시 입력, 중복 입력 등의 버그를 구조적으로 예방한다.
또한 향후 AI 차례일 땐 클릭 대신 AI 의사결정 로직을 같은 자리에 꽂을 수 있다.
6. 게임 규칙 및 판정
보드, 턴, 마커 배치, 승패/무승부 판정을 한 곳에서 총괄하는 게임 엔진을 생성한다.
using UnityEngine;
public class GameLogic
{
public BlockController blockController; // Block을 처리할 객체
private Constants.PlayerType[,] _board; // 보드의 상태 정보
public BasePlayerState firstPlayerState; // Player A
public BasePlayerState secondPlayerState; // Player B
public enum GameResult { None, Win, Lose, Draw }
private BasePlayerState _currentPlayerState; // 현재 턴의 Player
public GameLogic(BlockController blockController, Constants.GameType gameType)
{
this.blockController = blockController;
// 보드의 상태 정보 초기화
_board =
new Constants.PlayerType[Constants.BlockColumnCount, Constants.BlockColumnCount];
// Game Type 초기화
switch (gameType)
{
case Constants.GameType.SinglePlay:
break;
case Constants.GameType.DualPlay:
firstPlayerState = new PlayerState(true);
secondPlayerState = new PlayerState(false);
// 게임 시작
SetState(firstPlayerState);
break;
case Constants.GameType.MultiPlay:
break;
}
}
// 턴이 바뀔 때, 기존 진행하던 상태를 Exit 하고
// 이번 턴의 상태를 _currentPlayerState에 할당하고
// 이번 턴의 상탱에 Enter 호출
public void SetState(BasePlayerState state)
{
_currentPlayerState?.OnExit(this);
_currentPlayerState = state;
_currentPlayerState?.OnEnter(this);
}
// _board 배열에 새로운 Marker 값을 할당
public bool SetNewBoardValue(Constants.PlayerType playerType,
int row, int col)
{
if (_board[row, col] != Constants.PlayerType.None) return false;
if (playerType == Constants.PlayerType.PlayerA)
{
_board[row, col] = playerType;
blockController.PlaceMaker(Block.MarkerType.O, row, col);
return true;
}
else if (playerType == Constants.PlayerType.PlayerB)
{
_board[row, col] = playerType;
blockController.PlaceMaker(Block.MarkerType.X, row, col);
return true;
}
return false;
}
// Game Over 처리
public void EndGame(GameResult gameResult)
{
SetState(null);
firstPlayerState = null;
secondPlayerState = null;
// TODO: 유저에게 Game Over 표시
Debug.Log("### GAME OVER ###");
}
// 게임의 결과 확인
public GameResult CheckGameResult()
{
if (CheckGameWin(Constants.PlayerType.PlayerA, _board)) { return GameResult.Win; }
if (CheckGameWin(Constants.PlayerType.PlayerB, _board)) { return GameResult.Lose; }
if (CheckGameDraw(_board)) { return GameResult.Draw; }
return GameResult.None;
}
// 비겼는지 확인
public 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;
}
// 게임 승리 확인
private bool CheckGameWin(Constants.PlayerType playerType, Constants.PlayerType[,] board)
{
// Col 체크 후 일자면 True
for (var row = 0; row < board.GetLength(0); row++)
{
if (board[row, 0] == playerType &&
board[row, 1] == playerType &&
board[row, 2] == playerType)
{
return true;
}
}
// Row 체크 후 일자면 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;
}
}
// 대각선 일자면 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;
}
}
- BlockController 레퍼런스를 받아 UI와 통신하고 _board 2차원 배열로 현재 상태를 저장한다
- 생성자에서 GameType에 따라 상태를 구성한다. 현재 DualPlay에서만 A/B 두 상태를 만들고 게임을 시작한다
- SetState()는 기존 상태 OnExit → 새 상태 할당 → 새 상태 OnEnter 순서로 전환한다
- SetNewBoardValue()는 빈 칸만 허용하고 A면 O, B면 X를 놓으며 뷰에 반영한다
- EndGame()은 상태를 모두 비우고 종료 로그를 찍는다(추후 UI 연동 예정)
- CheckGameResult()는 승/패/무승부를 순서대로 검사한다
- CheckGameDraw()는 빈 칸이 하나라도 있으면 진행 중으로 본다
- CheckGameWin()은 행/열/대각선 일치 여부를 검사한다
UI 입력은 BlockController가 모으고
실제 판정은 GameLogic이 수행한다.
SetNewBoardValue에서 데이터(_board)와 표시(PlaceMaker)를 동시에 갱신하므로 상태와 뷰가 어긋나지 않는다.
6. 게임 매니저
using UnityEngine;
using UnityEngine.SceneManagement;
public class GameManager : Singleton<GameManager>
{
[SerializeField] private GameObject confirmPanel;
// Main Scene에서 선택한 게임 타입
private Constants.GameType _gameType;
// Panel을 띄우기 위한 Canvas 정보
private Canvas _canvas;
// Game Logic
private GameLogic _gameLogic;
/// <summary>
/// Main에서 Game Scene으로 전환시 호출될 메서드
/// </summary>
public void ChangeToGameScene(Constants.GameType gameType)
{
_gameType = gameType;
SceneManager.LoadScene("Game");
}
/// <summary>
/// Game에서 Main Scene으로 전환시 호출될 메서드
/// </summary>
public void ChangeToMainScene()
{
SceneManager.LoadScene("Main");
}
/// <summary>
/// Confirm Panel을 띄우는 메서드
/// </summary>
/// <param name="message"></param>
public void OpenConfirmPanel(string message,
ConfirmPanelController.OnConfirmButtonClicked onConfirmButtonClicked)
{
if (_canvas != null)
{
var confirmPanelObject = Instantiate(confirmPanel, _canvas.transform);
confirmPanelObject.GetComponent<ConfirmPanelController>()
.Show(message, onConfirmButtonClicked);
}
}
protected override void OnSceneLoad(Scene scene, LoadSceneMode mode)
{
_canvas = FindFirstObjectByType<Canvas>();
if (scene.name == "Game")
{
// Block 초기화
var blockController = FindFirstObjectByType<BlockController>();
blockController.InitBlocks();
// GameLogic 생성
if (_gameLogic != null)
{
// TODO: 기존 게임 로직을 소멸
}
_gameLogic = new GameLogic(blockController, _gameType);
}
}
}
- 메인에서 게임으로 넘어올 때 선택된 GameType을 저장한다
- OpenConfirmPanel(message, onConfirm) 형태로 바뀌어 확인 버튼 콜백을 외부에서 주입할 수 있다
- 씬 로드 시(OnSceneLoad) 캔버스를 찾아 캐싱하고, Game 씬이면
① BlockController.InitBlocks()로 보드 초기화 →
② 기존 GameLogic 정리 후 새 GameLogic(blockController, _gameType) 생성한다
씬 로드 이벤트를 훅으로 사용하면 어디서 게임 씬으로 넘어오든 동일한 초기화 절차가 보장된다.
콜백형 OpenConfirmPanel은 팝업을 재사용 가능한 UI 컴포넌트로 격상시켜 다양한 확인 동작에 대응할 수 있게 한다.
⭐ 흐름 요약(맥락용)
- 유저가 블록을 클릭하면 Block이 인덱스를 BlockController로 전달한다 →
- 컨트롤러가 (row, col)로 변환해 델리게이트 호출 →
- 현재 턴의 PlayerState가 ProcessMove를 통해 GameLogic에 수를 둔다 →
- GameLogic이 보드 갱신과 승패/무승부 판정을 처리하고 필요 시 상태 전환 또는 종료를 수행한다.