Transform 기반으로 구현하는 3D 그래픽 오목 승리 판정

Unity Transform + Dictionary 최적화 설계 보고서 (스킬 시스템 포함)


📋 프로젝트 개요

플랫폼: Unity 2021.3+ (또는 2022/2023 LTS)
게임 유형: 2D 평면 오목을 3D 그래픽으로 표현
보드 크기: 15×15 또는 19×19 평면 격자
핵심 기술: Transform 좌표 기반 탐색, Dictionary 캐싱, 스킬 시스템

🎯 성능 목표

  • 단일 수 판정: < 1ms @ 15×15 보드
  • 메모리 효율: O(n) 공간 복잡도 (n = 놓인 돌 개수)
  • 구현 복잡도: 낮음 (직관적이고 유지보수 쉬움)

💡 왜 Transform 기반인가? 평면 오목은 4방향만 체크하면 되므로 단순하고 직관적인 Transform 방식이 가장 적합합니다!


🌟 배경과 목표

일반적인 오목은 2D 평면 보드에서 진행되며, 승리 조건 확인을 위해 4개 방향만 검사하면 됩니다:

  • 가로: 좌 ↔ 우
  • 세로: 상 ↔ 하
  • 대각선1: 좌상 ↔ 우하 (↙ ↔ ↗)
  • 대각선2: 우상 ↔ 좌하 (↖ ↔ ↘)

추가 복잡성: 스킬 시스템

스킬이 도입되면 일부 돌을 판정에서 제외하는 특별한 규칙을 처리해야 합니다:

  • 스킬 대상 돌: 특정 스킬에 의해 영향받은 돌은 승리 판정에서 제외
  • 고정 승리 조건: 항상 5개 연속으로만 승리 (변경 없음)

본 보고서의 목적: Unity 3D 환경에서 Transform을 활용한 직관적이면서도 효율적인 승리 판정 시스템을 설계합니다.


🏗️ 핵심 아키텍처

1. 데이터 구조 설계

BoardManager 클래스:

public class BoardManager : MonoBehaviour
{
    [Header("보드 설정")]
    public int boardSize = 15;
    public float gridSize = 1f; // 격자 간격
    public Transform boardCenter; // 보드 중심점

    [Header("돌 설정")]  
    public GameObject blackStonePrefab;
    public GameObject whiteStonePrefab;

    // 핵심: 위치별 돌 빠른 검색용 Dictionary
    private Dictionary<Vector2Int, Stone> stoneMap = new Dictionary<Vector2Int, Stone>();
    
    // 게임 상태
    private int currentPlayer = 1; // 1=흑돌, 2=백돌
}

Stone 클래스:

[System.Serializable]
public class Stone : MonoBehaviour
{
    public int player; // 1=흑돌, 2=백돌
    public Vector2Int gridPosition; // 보드상 격자 좌표
    
    [Header("스킬 효과")]
    public bool isAffectedBySkill = false; // 스킬에 의해 영향받은 돌
}

2. 좌표 변환 시스템

좌표 변환 시스템:

public class CoordinateSystem
{
    private Vector3 boardCenter;
    private float gridSize;
    private int boardSize;
    
    // 월드 좌표 → 격자 좌표
    public Vector2Int WorldToGrid(Vector3 worldPos)
    {
        Vector3 offset = worldPos - boardCenter;
        int x = Mathf.RoundToInt(offset.x / gridSize) + boardSize / 2;
        int z = Mathf.RoundToInt(offset.z / gridSize) + boardSize / 2;
        return new Vector2Int(x, z);
    }
    
    // 격자 좌표 → 월드 좌표  
    public Vector3 GridToWorld(Vector2Int gridPos)
    {
        float x = (gridPos.x - boardSize / 2) * gridSize;
        float z = (gridPos.y - boardSize / 2) * gridSize;
        return boardCenter + new Vector3(x, 0, z);
    }
    
    // 격자 범위 검증
    public bool IsValidPosition(Vector2Int pos)
    {
        return pos.x >= 0 && pos.x < boardSize && pos.y >= 0 && pos.y < boardSize;
    }
}

🎯 승리 판정 핵심 알고리즘

1. 기본 승리 체크

메인 승리 판정 함수:

public bool CheckWinCondition(Transform lastStone, int player)
{
    Vector2Int gridPos = coordinateSystem.WorldToGrid(lastStone.position);
    
    // 4개 방향 벡터 정의
    Vector2Int[] directions = {
        new Vector2Int(1, 0),   // 가로 →
        new Vector2Int(0, 1),   // 세로 ↑  
        new Vector2Int(1, 1),   // 대각선1 ↗
        new Vector2Int(1, -1)   // 대각선2 ↘
    };

    foreach (var direction in directions)
    {
        int consecutiveCount = 1; // 현재 돌 포함
        
        // 정방향 카운트
        consecutiveCount += CountInDirection(gridPos, direction, player);
        
        // 역방향 카운트  
        consecutiveCount += CountInDirection(gridPos, -direction, player);
        
        // 승리 조건 확인 (항상 5개)
        if (consecutiveCount >= 5)
        {
            return true;
        }
    }
    
    return false;
}

방향별 카운트 함수:

private int CountInDirection(Vector2Int startPos, Vector2Int direction, int player)
{
    int count = 0;
    Vector2Int currentPos = startPos;
    
    for (int step = 1; step <= 4; step++) // 최대 4칸까지만 체크
    {
        currentPos += direction;
        
        // 보드 범위 체크
        if (!coordinateSystem.IsValidPosition(currentPos))
            break;
            
        // 해당 위치의 돌 확인
        if (stoneMap.TryGetValue(currentPos, out Stone stone))
        {
            // 스킬에 영향받은 돌은 카운트하지 않음
            if (stone.isAffectedBySkill)
            {
                break; // 스킬 영향 돌을 만나면 연속 중단
            }
            
            if (stone.player == player)
                count++;
            else
                break; // 다른 색 돌
        }
        else
        {
            break; // 빈 공간
        }
    }
    
    return count;
}

2. 스킬 시스템이 적용된 승리 판정

스킬 규칙 정의:

[System.Serializable]
public class SkillSettings
{
    [Header("스킬 영향 돌 처리")]
    public bool ignoreAffectedStones = true; // 스킬 영향 돌을 판정에서 제외
    
    [Header("고정 승리 조건")]
    public int requiredCount = 5; // 항상 5개로 고정
}

스킬이 적용된 승리 체크:

public bool CheckWinWithSkills(Transform lastStone, int player, SkillSettings skillSettings)
{
    Vector2Int gridPos = coordinateSystem.WorldToGrid(lastStone.position);
    Vector2Int[] directions = {
        new Vector2Int(1, 0), new Vector2Int(0, 1),
        new Vector2Int(1, 1), new Vector2Int(1, -1)
    };

    foreach (var direction in directions)
    {
        int totalCount = 1; // 현재 돌 포함
        
        // 스킬 규칙이 적용된 방향별 카운트
        totalCount += CountWithSkillRules(gridPos, direction, player, skillSettings);
        totalCount += CountWithSkillRules(gridPos, -direction, player, skillSettings);
        
        // 항상 5개로 승리 판정
        if (totalCount >= 5)
        {
            return true;
        }
    }
    
    return false;
}

스킬 규칙 적용된 카운트:

private int CountWithSkillRules(Vector2Int startPos, Vector2Int direction, int player, SkillSettings skillSettings)
{
    int count = 0;
    Vector2Int currentPos = startPos;
    
    for (int step = 1; step <= 4; step++)
    {
        currentPos += direction;
        
        if (!coordinateSystem.IsValidPosition(currentPos))
            break;
            
        if (stoneMap.TryGetValue(currentPos, out Stone stone))
        {
            // 스킬에 영향받은 돌은 판정에서 제외
            if (stone.isAffectedBySkill && skillSettings.ignoreAffectedStones)
            {
                break; // 연속 중단
            }
            
            // 같은 색 돌
            if (stone.player == player)
            {
                count++;
            }
            else
            {
                break; // 다른 색 돌
            }
        }
        else
        {
            break; // 빈 공간
        }
    }
    
    return count;
}

🎮 돌 놓기 시스템

1. 기본 돌 배치

돌 배치 메인 함수:

public bool PlaceStone(Vector3 worldPosition, int player)
{
    Vector2Int gridPos = coordinateSystem.WorldToGrid(worldPosition);
    
    // 이미 돌이 있는 위치인지 확인
    if (stoneMap.ContainsKey(gridPos))
    {
        Debug.LogWarning("이미 돌이 놓인 위치입니다!");
        return false;
    }
    
    // 돌 생성
    GameObject stonePrefab = GetStonePrefab(player);
    Vector3 exactWorldPos = coordinateSystem.GridToWorld(gridPos);
    GameObject stoneObj = Instantiate(stonePrefab, exactWorldPos, Quaternion.identity);
    
    // Stone 컴포넌트 설정
    Stone stone = stoneObj.GetComponent<Stone>();
    stone.player = player;
    stone.gridPosition = gridPos;
    
    // Dictionary에 저장 (빠른 검색용)
    stoneMap[gridPos] = stone;
    
    // 승리 조건 체크
    if (CheckWinCondition(stoneObj.transform, player))
    {
        OnGameWin(player);
        return true;
    }
    
    // 턴 교체
    currentPlayer = (currentPlayer == 1) ? 2 : 1;
    return true;
}

돌 프리팹 선택:

private GameObject GetStonePrefab(int player)
{
    return player switch
    {
        1 => blackStonePrefab,
        2 => whiteStonePrefab,
        _ => blackStonePrefab
    };
}

2. 스킬 효과 적용

스킬 효과 적용 함수:

public void ApplySkillEffect(Vector2Int targetPosition)
{
    if (stoneMap.TryGetValue(targetPosition, out Stone targetStone))
    {
        // 스킬에 의해 영향받은 돌로 표시
        targetStone.isAffectedBySkill = true;
        
        // 시각적 효과 적용 (예: 색상 변경, 이펙트 등)
        ApplyVisualEffect(targetStone);
        
        Debug.Log($"스킬 효과가 {targetPosition} 위치의 돌에 적용되었습니다.");
    }
}

스킬 효과 제거:

public void RemoveSkillEffect(Vector2Int targetPosition)
{
    if (stoneMap.TryGetValue(targetPosition, out Stone targetStone))
    {
        targetStone.isAffectedBySkill = false;
        RemoveVisualEffect(targetStone);
        
        Debug.Log($"스킬 효과가 {targetPosition} 위치의 돌에서 제거되었습니다.");
    }
}

시각적 효과 처리:

private void ApplyVisualEffect(Stone stone)
{
    // 스킬 영향 돌의 시각적 표시 (예: 반투명, 색상 변경 등)
    Renderer renderer = stone.GetComponent<Renderer>();
    if (renderer != null)
    {
        Color color = renderer.material.color;
        color.a = 0.5f; // 반투명 처리
        renderer.material.color = color;
    }
}

private void RemoveVisualEffect(Stone stone)
{
    // 원래 상태로 복원
    Renderer renderer = stone.GetComponent<Renderer>();
    if (renderer != null)
    {
        Color color = renderer.material.color;
        color.a = 1f; // 불투명 처리
        renderer.material.color = color;
    }
}

🔧 입력 처리 시스템

마우스 클릭으로 돌 놓기:

public class InputHandler : MonoBehaviour
{
    public BoardManager boardManager;
    public Camera mainCamera;
    
    void Update()
    {
        if (Input.GetMouseButtonDown(0)) // 좌클릭
        {
            HandleMouseClick();
        }
    }
    
    private void HandleMouseClick()
    {
        Ray ray = mainCamera.ScreenPointToRay(Input.mousePosition);
        
        if (Physics.Raycast(ray, out RaycastHit hit))
        {
            Vector3 clickPosition = hit.point;
            int currentPlayer = boardManager.GetCurrentPlayer();
            
            boardManager.PlaceStone(clickPosition, currentPlayer);
        }
    }
}

터치 입력 지원:

private void HandleTouchInput()
{
    if (Input.touchCount > 0)
    {
        Touch touch = Input.GetTouch(0);
        
        if (touch.phase == TouchPhase.Began)
        {
            Ray ray = mainCamera.ScreenPointToRay(touch.position);
            
            if (Physics.Raycast(ray, out RaycastHit hit))
            {
                Vector3 touchPosition = hit.point;
                int currentPlayer = boardManager.GetCurrentPlayer();
                
                boardManager.PlaceStone(touchPosition, currentPlayer);
            }
        }
    }
}

🎯 성능 최적화 팁

Dictionary 활용한 빠른 검색:

// ❌ 느린 방법: 모든 돌 순회
foreach (Stone stone in allStones)
{
    if (stone.gridPosition == targetPosition)
        return stone;
}

// ✅ 빠른 방법: Dictionary 직접 접근 O(1)
if (stoneMap.TryGetValue(targetPosition, out Stone stone))
{
    return stone;
}

메모리 효율적인 방향 벡터:

// ✅ static readonly로 메모리 절약
private static readonly Vector2Int[] Directions = {
    new Vector2Int(1, 0), new Vector2Int(0, 1),
    new Vector2Int(1, 1), new Vector2Int(1, -1)
};

조기 종료 최적화:

private int CountInDirection(Vector2Int startPos, Vector2Int direction, int player)
{
    int count = 0;
    Vector2Int currentPos = startPos;
    
    // 최대 4칸만 체크 (5연속 확인용)
    for (int step = 1; step <= 4; step++)
    {
        currentPos += direction;
        
        // 범위 체크로 조기 종료
        if (!coordinateSystem.IsValidPosition(currentPos))
            break;
            
        if (stoneMap.TryGetValue(currentPos, out Stone stone))
        {
            if (stone.player == player && !stone.isAffectedBySkill)
                count++;
            else
                break; // 다른 돌이면 즉시 중단
        }
        else
        {
            break; // 빈 공간이면 즉시 중단
        }
    }
    
    return count;
}

🧪 테스트 & 검증 계획

필수 테스트 케이스

정확성 검증:

[Test]
public void TestBasicWinCondition()
{
    // 기본 5연속 승리 조건 테스트
    BoardManager board = new BoardManager();
    
    // 가로 5연속 테스트
    for (int i = 0; i < 5; i++)
    {
        board.PlaceStone(new Vector3(i, 0, 0), 1);
    }
    
    Assert.IsTrue(board.CheckWinCondition(lastStone, 1));
}

스킬 시스템 테스트:

[Test]
public void TestSkillAffectedStones()
{
    // 스킬 영향 돌이 승리 판정에서 제외되는지 테스트
    BoardManager board = new BoardManager();
    
    // 5연속 배치
    for (int i = 0; i < 5; i++)
    {
        board.PlaceStone(new Vector3(i, 0, 0), 1);
    }
    
    // 중간 돌에 스킬 효과 적용
    board.ApplySkillEffect(new Vector2Int(2, 0));
    
    // 승리 조건이 false가 되어야 함
    Assert.IsFalse(board.CheckWinWithSkills(lastStone, 1, skillSettings));
}

성능 벤치마크:

[Test]
public void BenchmarkWinCheck()
{
    BoardManager board = SetupRandomBoard(15, 100); // 15x15, 100개 돌
    
    Stopwatch sw = Stopwatch.StartNew();
    
    for (int i = 0; i < 1000; i++)
    {
        board.CheckWinCondition(randomStone, 1);
    }
    
    sw.Stop();
    Assert.Less(sw.ElapsedMilliseconds / 1000f, 1f); // 평균 1ms 이하
}

🔍 Transform vs 다른 방식 비교

방식 구현 복잡도 성능 확장성 메모리 효율 추천도
Transform + Dictionary 낮음 ⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐
Raycast 기반 중간 ⭐⭐⭐ ⭐⭐ ⭐⭐⭐ ⭐⭐⭐
Job System + Burst 높음 ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐

🏁 결론 및 권장사항

📊 방식별 선택 가이드

프로젝트 규모 권장 방식 이유
소규모/프로토타입 Transform 기반 빠른 개발, 직관적
중규모/일반 게임 Transform + Dictionary 최적의 성능/개발 균형
대규모/고성능 Job System 고려 극한 최적화 필요시만

✨ 핵심 인사이트

  1. 평면 오목에서는 Transform + Dictionary 조합이 가장 실용적이고 효율적입니다.

  2. Dictionary 캐싱이 성능의 핵심 - O(1) 검색으로 빠른 돌 찾기가 가능합니다.

  3. 스킬 시스템은 간단한 bool 플래그로 충분히 구현 가능하며, 복잡한 최적화는 불필요합니다.


🎯 최종 성능 목표 달성

  • 단일 수 판정: < 1ms @ 15×15 보드
  • 메모리 효율: O(n) 공간 복잡도
  • 구현 단순성: 직관적이고 유지보수 쉬움
  • 확장성: 스킬 시스템 지원

이 가이드는 ai가 제작했습니다.