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

[멋쟁이사자처럼부트캠프] 유니티 게임 개발 5기(74일차) - 모바일 틱택톡 게임 구현 (2)

by 독기품은토끼 2025. 9. 3.
✅ 오늘의 학습 목표
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이 보드 갱신과 승패/무승부 판정을 처리하고 필요 시 상태 전환 또는 종료를 수행한다.