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

[멋쟁이사자처럼부트캠프] 유니티 게임 개발 5기(75일차) - 모바일 틱택톡 게임 구현 (3) & MinMax 알고리즘 & Node.js를 통한 서버 밑작업

by 독기품은토끼 2025. 9. 4.
✅ 오늘의 학습 목표
1. 모바일 틱택톡 게임 구현 (3)
- 플레이 모드 (싱글 구현)
- 서버 밑작업

1. 싱글 플레이 모드 구현

1. AI 플레이어

using UnityEngine;

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

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

        // AI 연산
        var result = TicTacToeAI.GetBestMove(board);
        if (result.HasValue)
        {
            HandleMove(gameLogic, result.Value.row, result.Value.col);
        }
        else
        {
            gameLogic.EndGame(GameLogic.GameResult.Draw);
        }
    }

    public override void OnExit(GameLogic gameLogic)
    {
        
    }

    public override void HandleMove(GameLogic gameLogic, int row, int col)
    {
        ProcessMove(gameLogic, Constants.PlayerType.PlayerB, row, col);
    }

    protected override void HandleNextTurn(GameLogic gameLogic)
    {
        gameLogic.SetState(gameLogic.firstPlayerState);
    }
}

 

  • OnEnter(GameLogic): “이제 AI 차례다”라는 신호를 UI에 보여준 뒤 보드 상태를 읽어 최적 수를 계산하고 바로 둔다. (좌표가 없으면 무승부로 종료한다)
  • HandleMove(...): 공통 처리 루틴(ProcessMove)을 호출해 보드 반영 → 승패/무승부 판정 → 다음 턴 전환까지 한 번에 처리
  • HandleNextTurn(...): 수를 둔 뒤에는 다시 사람 차례(firstPlayerState)로 넘긴다.

핵심은 OnEnter에서 기다리지 않고 즉시 수를 두는 것이다.

사람은 클릭을 기다려야 하지만 AI는 입력이 필요 없으므로 바로 둬도 된다.

 

 

상태 패턴

“누구 차례인가?”를 객체로 분리해 두면

입력 연결/차단, UI 표시, 수 처리 타이밍 같은 것을 상태 전환(Enter/Exit)에 붙여 깔끔하게 관리할 수 있다.

 

동기 흐름

AI는 마우스 입력이 없으므로 OnEnter→계산→즉시 수 두기→다음 턴 흐름으로 끝난다.

이 덕분에 프레임 지연 없이 자연스럽게 진행된다.

 

 

2. MinMax 알고리즘

AI가 앞으로의 경우의 수를 전부 시뮬레이션해서

바보 같은 수를 두지 않게 하기 위해 MinMax 알고리즘을 활용해 주었다.

using UnityEngine;

public static class TicTacToeAI
{
    // 현재 상태를 전달하면 다음 최적의 수를 반환하는 메서드
    public static (int row, int col)? GetBestMove(Constants.PlayerType[,] board)
    {
        float bestScore = -1000;
        (int row, int col) movePosition = (-1, -1);

        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 = TicTacToeAI.DoMiniMax(board, 0, false);
                    board[row, col] = Constants.PlayerType.None;
                    if (score > bestScore)
                    {
                        bestScore = score;
                        movePosition = (row, col);
                    }
                }
            }
        }

        if (movePosition != (-1, -1))
        {
            return (movePosition.row, movePosition.col);
        }
        return null;
    }

    private static float DoMiniMax(Constants.PlayerType[,] board, int depth, bool isMaximizing)
    {
        // 게임 종료 상태 체크
        if (CheckGameWin(Constants.PlayerType.PlayerA, board))
            return -10 + depth;
        if (CheckGameWin(Constants.PlayerType.PlayerB, board))
            return 10 - depth;
        if (CheckGameDraw(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] == Constants.PlayerType.None)
                    {
                        board[row, col] = Constants.PlayerType.PlayerB;
                        var score = DoMiniMax(board, depth + 1, false);
                        board[row, col] = Constants.PlayerType.None;
                        bestScore = Mathf.Max(score, bestScore);
                    }
                }
            }
            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] == Constants.PlayerType.None)
                    {
                        board[row, col] = Constants.PlayerType.PlayerA;
                        var score = DoMiniMax(board, depth + 1, true);
                        board[row, col] = Constants.PlayerType.None;
                        bestScore = Mathf.Min(score, bestScore);
                    }
                }
            }
            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;
    }

    // 게임 승리 확인
    public static 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;
    }
}

 

  • GetBestMove(board)
    • 빈칸을 하나씩 가정해 시뮬레이션한다.
    • 가정한 수를 임시로 두고(배치) DoMiniMax(...)로 그 다음부터의 결과 점수를 계산한다.
    • 점수가 가장 좋은 칸을 최종 선택한다.
    • 임시로 둔 수는 반드시 되돌린다(백트래킹)
  • DoMiniMax(board, depth, isMaximizing)
    • 터미널(끝) 상태 검사: 사람 승(-10 + depth), AI 승(10 - depth), 무승부(0)를 즉시 반환한다.
    • AI 차례(isMaximizing=true): 가능한 모든 수에 대해 재귀 호출을 돌려 최대 점수를 고른다.
    • 사람 차례(isMaximizing=false): 가능한 모든 수에 대해 재귀 호출을 돌려 최소 점수를 고른다.
    • 각 재귀 호출 전후로 수를 놓고 → 평가 → 다시 지우는 백트래킹을 수행한다.
  • CheckGameWin / CheckGameDraw
    • 가로/세로/대각선이 같은 마커면 승리로 본다.
    • 빈칸이 하나도 없으면 무승부다.

 

[원리 설명(재귀 + 미니맥스 직관)]

  • 재귀(Recursion)란?
    문제를 더 작은 하위 문제로 쪼개서 같은 방식으로 반복해서 푸는 기법이다. 틱택토에서는 한 수 둔 뒤 남은 게임이 다시 같은 틱택토 문제이므로 똑같이 해결(재귀)하면 된다.
  • 미니맥스의 직관
    • AI 차례: 내가 이길 가능성이 가장 큰 수를 고른다(최대화)
    • 사람 차례: AI가 지게 만들 가능성이 가장 큰 수를 고른다(최소화)
      최대화/최소화를 번갈아 하면서 끝까지 도달했을 때의 결과 점수가 거꾸로 위로 올라오며(재귀 반환) 현재 수의 가치를 평가하게 된다.
  • depth를 점수에 더하고/빼는 이유는 빨리 이길수록 더 좋고 지더라도 가능하면 최대한 늦게 지는 편이 낫기 때문이다.
  • 그래서 AI 승리는 +10 - depth, 사람 승리는 -10 + depth처럼 점수에 깊이를 가미해 빠른 승리 느린 패배를 선호하게 만든다.

출처 : 강사님 피그마

 

 

2. 서버 만들기

🥕 예행 작업
1. SW 다운로드
 

Node.js — Node.js® 다운로드

Node.js® is a free, open-source, cross-platform JavaScript runtime environment that lets developers create servers, web apps, command line tools and scripts.

nodejs.org

 

Download Postman | Get Started for Free

Try Postman for free! Join 40 million users who rely on Postman, the collaboration platform for API development. Create better APIs—faster.

www.postman.com

 

Try MongoDB Community Edition

Try MongoDB Community Edition on premise non-relational database including the Community Server and Community Kubernetes Operator for your next big project!

www.mongodb.com

 

Download Visual Studio Code - Mac, Linux, Windows

Visual Studio Code is free and available on your favorite platform - Linux, macOS, and Windows. Download Visual Studio Code to experience a redefined code editor, optimized for building and debugging modern web and cloud applications.

code.visualstudio.com

 

1. Node.js와 Express

우리가 지금까지 만든 틱택토는 유니티 클라이언트에서만 동작한다.

그런데 멀티플레이를 하거나, 로그인/점수 저장 같은 기능을 하려면 서버가 필요하다.

 

  • Node.js
    • 자바스크립트(JavaScript)로 서버를 만들 수 있게 해주는 런타임 환경이다.
    • 원래 자바스크립트는 브라우저에서만 실행됐지만 Node.js 덕분에 서버에서도 사용할 수 있다.
    • 장점: 빠른 속도, 많은 라이브러리, 자바스크립트를 그대로 활용 가능.
  • Express
    • Node.js 위에서 동작하는 가장 대표적인 서버 프레임워크
    • 웹 서버를 쉽게 만들 수 있게 도와주는 도구 모음이라고 생각하면 된다.
    • 요청(Request)과 응답(Response)을 간단한 코드로 처리할 수 있다.

 

2. 프로젝트 생성

원하는 폴더를 생성한 후 해당 폴더 안에서 cmd 창을 열어 서버 프로젝트를 생성해 준다.

npm install express-generator -g
express tictactoe-server
cd tictactoe-server
npm install

 

  • express-generator를 전역(-g)으로 설치
  • express tictactoe-server 명령어로 tictactoe-server라는 프로젝트 생성
  • npm install로 필요한 모듈을 설치
npm start

 

해당 명령어로 서버를 실행시켜 제대로 작동되는지 확인할 수 있다.

브라우저에서 localhost:3000 으로 접속하면 Express에서 제공하는 기본 페이지가 보인다면 정상이다.

 

3. 라이브러리 설치

npm install bcrypt mongodb express-session session-file-store socket.io uuid

 

  • bcrypt: 비밀번호를 안전하게 암호화
  • mongodb: NoSQL 데이터베이스. 유저 정보나 전적 기록 저장에 활용
  • express-session: 로그인 상태를 유지하는 세션 기능
  • session-file-store: 세션 정보를 파일에 저장
  • socket.io: 실시간 통신 (멀티플레이 구현에 핵심)
  • uuid: 고유한 ID 생성 (유저 ID, 방 번호 등)

 

 

라이브러리가 모두 설치되면 json 파일에 자동 업데이트 된다.