Unity 타워 디펜스 - 타워 클릭 시스템 개발기 🎮

5P2RS5·2026년 2월 8일

컴퓨터 살리기

목록 보기
6/8
post-thumbnail

2D 하이브리드 타워 디펜스 게임의 핵심 시스템 개발 과정
(지금까지의 작업물을 AI가 작성해준 개발일지)

📋 개요

빌드 시스템을 완성한 후, 이제 배치된 타워와 상호작용할 수 있는 시스템이 필요했습니다. 타워를 클릭하면 사거리를 표시하고, 타겟팅 정보를 디버깅할 수 있는 기능을 구현했습니다.

구현 목표:

  • 타워 클릭 시 사거리 표시 (Range Indicator)
  • 한 번에 하나의 타워만 선택 가능
  • 빌드 모드 중에는 타워 선택 불가
  • 타겟팅 시스템 디버그 기능 (Console + Gizmos)

구현 파일:

  • TowerSelector.cs - 타워 선택 관리 (Singleton)
  • TowerClickHandler.cs - 타워 클릭 영역 핸들러
  • Tower.cs - 타워 베이스 클래스 (Range Indicator 추가)
  • TowerTargeting.cs - 타겟팅 시스템 (디버그 기능 추가)

1️⃣ 타워 클릭 시스템 설계

아키텍처 구조

Tower GameObject (부모)
├─ CircleCollider2D (적 탐지용 - Layer: Tower)
├─ Tower Component (BasicTower, LaserTower 등)
├─ TowerTargeting Component
├─ SpriteRenderer
├─ RangeIndicator (범위 링 - 초기 숨김)
└─ ClickArea (자식 GameObject - 클릭 감지용)
├─ BoxCollider2D (Is Trigger ✅, Layer: Tower)
└─ TowerClickHandler Component

핵심 설계 원칙:

  1. Collider 분리: CircleCollider2D (적 탐지) + BoxCollider2D (클릭 감지)를 별도로 사용
  2. 자식 오브젝트 패턴: ClickArea를 자식으로 두고 TowerClickHandler로 부모 참조
  3. Singleton TowerSelector: 전역에서 하나의 타워만 선택 관리
  4. Build Mode 연동: BuildManager의 상태를 체크하여 빌드 중에는 타워 선택 불가

2️⃣ 구현 과정 & 트러블슈팅

🔴 문제 1: Input System 충돌

증상:
InvalidOperationException: You are trying to read Input using the UnityEngine.Input class,
but you have switched active Input handling to Input System package.

원인: Unity New Input System 프로젝트에서 Old Input System API 사용

// ❌ Old Input System (작동 안 함)
if (Input.GetMouseButtonDown(0))
해결: New Input System API로 전환


// ✅ New Input System (Unity 6)
using UnityEngine.InputSystem;

if (Mouse.current.leftButton.wasPressedThisFrame)
{
    Vector2 mousePos = Camera.main.ScreenToWorldPoint(Mouse.current.position.ReadValue());
}

🔴 문제 2: CircleCollider2D가 클릭을 막음

증상:

ClickArea의 BoxCollider2D를 클릭해도 반응 없음
CircleCollider2D를 비활성화하면 클릭이 정상 작동
원인: Physics2D.Raycast()는 첫 번째 히트만 반환 → CircleCollider2D가 먼저 감지되어 BoxCollider2D까지 도달하지 못함

초기 시도 (실패):

// ❌ 첫 번째 Collider만 감지
RaycastHit2D hit = Physics2D.Raycast(mousePos, Vector2.zero);
if (hit.collider != null)
{
    TowerClickHandler handler = hit.collider.GetComponent<TowerClickHandler>();
    // handler는 항상 null (CircleCollider2D가 먼저 잡힘)
}
해결: RaycastAll 사용


// ✅ 모든 Collider 감지 후 TowerClickHandler 찾기
RaycastHit2D[] allHits = Physics2D.RaycastAll(mousePos, Vector2.zero);

TowerClickHandler towerClickHandler = null;
for (int i = 0; i < allHits.Length; i++)
{
    TowerClickHandler handler = allHits[i].collider.GetComponent<TowerClickHandler>();
    if (handler != null)
    {
        towerClickHandler = handler;
        break;
    }
}

배운 점:

하나의 GameObject에 여러 Collider가 있을 때는 RaycastAll() 사용 필수
각 Collider의 용도를 명확히 분리 (적 탐지 vs 클릭 감지)

🔴 문제 3: OnMouseDown()이 작동하지 않음

초기 시도 (실패):

// TowerClickHandler.cs
void OnMouseDown()
{
    Debug.Log("Tower clicked!"); // 호출 안 됨
}

원인: OnMouseDown()은 Old Input System에서만 작동. New Input System에서는 비활성화됨.

해결: TowerSelector에서 Update()로 직접 처리

// TowerSelector.cs - Update()에서 직접 입력 처리
void Update()
{
    if (Mouse.current.leftButton.wasPressedThisFrame)
    {
        // 빌드 모드 체크
        if (buildManager != null && buildManager.IsBuildModeActive)
        {
            Debug.Log("⚠️ 빌드 모드 활성화 중 - 타워 선택 불가");
            return;
        }

        // Raycast로 클릭 감지
        Vector2 mousePos = Camera.main.ScreenToWorldPoint(Mouse.current.position.ReadValue());
        RaycastHit2D[] allHits = Physics2D.RaycastAll(mousePos, Vector2.zero);
        
        // TowerClickHandler 찾기
        // ...
    }
}

🔴 문제 4: TowerClickHandler의 parentTower 참조

초기 구현:

// ❌ GetComponentInParent로 자동 탐색
private Tower parentTower;

void Awake()
{
    parentTower = GetComponentInParent<Tower>();
}

개선된 해결책:

// ✅ Inspector에서 직접 할당 (더 명확하고 안전함)
public Tower parentTower; // public으로 변경

void Awake()
{
    if (parentTower == null)
    {
        Debug.LogError($"❌ {gameObject.name}의 parentTower가 할당되지 않았습니다!");
    }
}

TowerSelector에서 사용:

Tower tower = towerClickHandler.parentTower; // public 필드 직접 접근

배운 점:

GetComponentInParent()보다 Inspector 할당이 더 명확하고 디버깅 쉬움
복잡한 Hierarchy에서는 자동 탐색보다 명시적 참조가 안전

3️⃣ 타워 선택 시스템 완성

TowerSelector.cs (Singleton)

public class TowerSelector : MonoBehaviour
{
    private static TowerSelector instance;
    private Tower currentSelectedTower;

    void Awake()
    {
        // 싱글톤 패턴
        if (instance == null)
            instance = this;
        else
            Destroy(gameObject);
    }

    void Update()
    {
        if (Mouse.current.leftButton.wasPressedThisFrame)
        {
            // 1. 빌드 모드 체크
            BuildManager buildManager = FindObjectOfType<BuildManager>();
            if (buildManager != null && buildManager.IsBuildModeActive)
                return;

            // 2. 마우스 위치 Raycast
            Vector2 mousePos = Camera.main.ScreenToWorldPoint(Mouse.current.position.ReadValue());
            RaycastHit2D[] allHits = Physics2D.RaycastAll(mousePos, Vector2.zero);

            // 3. TowerClickHandler 찾기
            TowerClickHandler towerClickHandler = null;
            foreach (var hit in allHits)
            {
                TowerClickHandler handler = hit.collider.GetComponent<TowerClickHandler>();
                if (handler != null)
                {
                    towerClickHandler = handler;
                    break;
                }
            }

            if (towerClickHandler == null)
            {
                DeselectCurrentTower(); // 빈 곳 클릭 시 선택 해제
                return;
            }

            Tower tower = towerClickHandler.parentTower;

            // 4. 같은 타워 클릭 시 토글
            if (currentSelectedTower == tower)
            {
                tower.ToggleRangeIndicator();
                if (!tower.isSelected)
                    currentSelectedTower = null;
            }
            else
            {
                // 5. 이전 타워 선택 해제 후 새 타워 선택
                if (currentSelectedTower != null)
                    currentSelectedTower.HideRangeIndicator();

                SelectTower(tower);
                tower.ShowRangeIndicator();
            }
        }
    }

    public static void SelectTower(Tower tower)
    {
        if (instance == null) return;
        instance.currentSelectedTower = tower;
    }

    public static void DeselectCurrentTower()
    {
        if (instance == null || instance.currentSelectedTower == null) return;
        instance.currentSelectedTower.HideRangeIndicator();
        instance.currentSelectedTower = null;
    }
}
Tower.cs - Range Indicator 메서드

[Header("Visual")]
public GameObject rangeIndicator; // 범위 링 (자식 오브젝트)
public bool isSelected = false;

public void ShowRangeIndicator()
{
    isSelected = true;
    if (rangeIndicator != null)
        rangeIndicator.SetActive(true);
}

public void HideRangeIndicator()
{
    isSelected = false;
    if (rangeIndicator != null)
        rangeIndicator.SetActive(false);
}

public void ToggleRangeIndicator()
{
    isSelected = !isSelected;
    if (rangeIndicator != null)
        rangeIndicator.SetActive(isSelected);
}

4️⃣ 디버그 시스템 구현

구현 목표
Console에 탐지된 적 목록 출력 (이름, 체력, 거리)
Scene 뷰에 Gizmos로 시각화 (사거리, 탐지된 적, 현재 타겟)

TowerTargeting.cs - 디버그 기능 추가

[Header("Debug")]
public bool showDebugInfo = true;
private List<Enemy> detectedEnemies = new List<Enemy>();

void ScanForEnemies()
{
    Collider2D[] enemiesInRange = Physics2D.OverlapCircleAll(
        transform.position,
        tower.range,
        enemyLayer
    );

    // 탐지된 적 리스트 업데이트
    detectedEnemies.Clear();
    foreach (var collider in enemiesInRange)
    {
        Enemy enemy = collider.GetComponent<Enemy>();
        if (enemy != null)
            detectedEnemies.Add(enemy);
    }

    // 🔍 Console 디버그 출력
    if (showDebugInfo && detectedEnemies.Count > 0)
    {
        Debug.Log($"🎯 [{tower.towerName}] 탐지된 적: {detectedEnemies.Count}명");
        for (int i = 0; i < detectedEnemies.Count; i++)
        {
            Enemy enemy = detectedEnemies[i];
            float distance = Vector3.Distance(transform.position, enemy.transform.position);
            Debug.Log($"  [{i}] {enemy.name} | 체력: {enemy.currentHealth:F1}/{enemy.maxHealth} | 거리: {distance:F2}");
        }
    }

    // 타겟 선택 로직...
}

// 🎨 Gizmos 시각화
void OnDrawGizmosSelected()
{
    if (tower == null) tower = GetComponent<Tower>();

    // 빨간 원: 사거리
    Gizmos.color = Color.red;
    Gizmos.DrawWireSphere(transform.position, tower.range);

    if (Application.isPlaying && detectedEnemies.Count > 0)
    {
        foreach (Enemy enemy in detectedEnemies)
        {
            if (enemy != null)
            {
                // 노란 선: 타워 → 적
                Gizmos.color = Color.yellow;
                Gizmos.DrawLine(transform.position, enemy.transform.position);

                // 하늘색 원: 적 위치
                Gizmos.color = Color.cyan;
                Gizmos.DrawWireSphere(enemy.transform.position, 0.3f);
            }
        }

        // 초록 선: 현재 타겟 강조
        if (tower.CurrentTarget != null)
        {
            Gizmos.color = Color.green;
            Gizmos.DrawLine(transform.position, tower.CurrentTarget.position);
            Gizmos.DrawWireSphere(tower.CurrentTarget.position, 0.5f);
        }
    }
}

public List<Enemy> GetDetectedEnemies()
{
    return detectedEnemies;
}
Tower.cs - CurrentTarget Property 추가

protected Transform currentTarget;

// 외부에서 현재 타겟을 읽을 수 있도록 (읽기 전용)
public Transform CurrentTarget => currentTarget;

5️⃣ Range Detection 버그 수정

🔴 문제: 거리 5.20에 있는 적이 공격받지 않음

디버그 출력:

🎯 [BasicTower] 탐지된 적: 1명
[0] TestEnemy | 체력: 100.0/100 | 거리: 5.20
원인 분석:

Physics2D.OverlapCircleAll(position, range) → range 5.0으로 CircleCollider의 경계를 기준으로 감지
Enemy의 Collider 경계가 5.0 이내 → 감지됨 ✅
하지만 Enemy의 Transform 중심은 5.20 → range 체크 실패 ❌

// Tower.cs - Update()
if (currentTarget != null)
{
    // ❌ 정확한 거리로 체크 → 5.20 > 5.0 → 타겟 해제
    if (Vector3.Distance(transform.position, currentTarget.position) > range)
    {
        currentTarget = null;
    }
}

해결: Tolerance 추가

// ✅ +0.3f tolerance 추가
if (Vector3.Distance(transform.position, currentTarget.position) > range + 0.3f)
{
    currentTarget = null;
}

배운 점:

OverlapCircleAll()은 Collider 경계 기준 감지
Transform 중심까지의 거리는 약간 다를 수 있음
Tolerance를 통해 경계 케이스 처리 필요

6️⃣ 최종 구현 결과

✅ 완성된 기능

타워 클릭 시스템

New Input System 기반 클릭 감지
RaycastAll로 다중 Collider 처리
TowerClickHandler를 통한 부모 Tower 참조
타워 선택 관리

Singleton TowerSelector

한 번에 하나의 타워만 선택
빌드 모드 중 선택 불가
같은 타워 재클릭 시 토글
Range Indicator

선택 시 사거리 표시

자식 GameObject로 구현
ShowRangeIndicator() / HideRangeIndicator() / ToggleRangeIndicator()
디버그 시스템

Console 로그: 탐지된 적 목록 (이름, 체력, 거리)

Gizmos 시각화:
빨간 원: 사거리
노란 선: 탐지된 적
하늘색 원: 적 위치
초록 선: 현재 타겟 (강조)
GetDetectedEnemies() API
Range Detection 개선

+0.3f tolerance로 경계 케이스 처리
Collider 경계와 Transform 중심 차이 보정

📂 Scene 구성

Persistent Scene (DontDestroyOnLoad)

TowerSelector (Singleton)
MainFieldScene

BuildManager
GridCursor
Tilemap (Ground, Build)
Towers (배치 시 동적 생성)

🎮 사용 방법

타워 건설: I 키 → 빌드 모드 ON → 1/2/3 키로 타워 선택 → 클릭으로 배치
타워 선택: 타워 클릭 → Range Indicator 표시
선택 해제: 빈 곳 클릭 or 같은 타워 재클릭
디버그: Scene 뷰에서 타워 선택 → Gizmos로 타겟팅 정보 확인

7️⃣ 트러블슈팅 요약

문제원인해결
Input System 에러Old Input System API 사용Mouse.current.leftButton.wasPressedThisFrame
CircleCollider가 클릭 막음Raycast() 첫 히트만 반환RaycastAll() + 순회
OnMouseDown 작동 안 함New Input System 비호환Update()에서 직접 처리
5.20 거리 공격 안 됨Collider 경계 vs Transform 중심range + 0.3f tolerance

협업 도구: ChatGPT (글작성 컨펌), Claude (구현)
저장소: https://github.com/5P2RS5/Computer-Defense

profile
☀️ Infra_Architecture 🔁 🌔 Game_Dev

0개의 댓글