[Unity] 타워 디펜스 타워 구매 및 배치 시스템

 

https://youtu.be/k0OkqIadB9Y

 

개요

이번엔 타워 배치 시스템을 구현해보았다.

 

먼저 전체적인 로직 흐름은 다음과 같다.

 

타워 선택 -> 타워 상세정보 표시 ->  타워 구매버튼 클릭  ->  배치 가능한 지형 위로 드래그  ->  배치 가능 지형에 배치 확정 시 스테이지 포인트 차감

 

TowerDataSO라는 타워 관리 ScriptableObject 데이터를 통해 TowerListView에 표시하고, View에 표시되는 타워 요소인 TowerListItem을 선택해 TowerSelectionController가 View간의 Controller역할을 해 마찬가지로 SO를 넘겨 TowerInfoView에 선택한 타워의 정보를 표시하기까지는 큰 어려움이 없었던 것 같다.

 

다만 타워의 더미를 만들고, 해당 타워의 공격 범위를 표시하고, 드래그 및 카메라 이동을 조작하는 로직을 작성하기에는 난이도가 있었기 때문에 어쩔 수 없이 GPT의 도움을 받고 구현 진행 후 가능한 내 것으로 만들기 위해 글을 남긴다.

 

대략적인 클래스 구분

  • GameManager : 스테이지 포인트, 웨이브 관리, 스테이지 결과 처리
  • TowerListView : TowerDataSO를 통해 만들어진 SO 에셋들에 대한 타워 리스트를 표시하는 View
  • TowerListItem : TowerListView에 표시되는 타워 요소에 대한 View 아이템
  • TowerInfoView :  선택한 타워에 대한 세부 정보 및 구매 등의 버튼 요소가 담긴 View
  • TowerSelectionController : TowerListView, TowerListItem, TowerInfoView 간의 상호작용 중간 매개체 역할을 하는 Controller
  • TowerPlacementController : 선택한 타워에 대한 프리뷰 생성, 이동, 검증, 설치 확정, 화면 엣지 Pan 트리거
  • CameraController : 화면 드래그를 통한 Pan, Pinch, 휠 Zoom, 경계 클램프, 엣지 Pan 이동 보조
  • RangeIndicator : 타워의 공격 범위 시각적 표시

 

TowerPlacementController :  프리뷰 이동, 검증, 엣지 Pan

1) 배치 시작 시

previewRoot.layer = LayerMask.NameToLayer("Ignore Raycast");
foreach (var c in previewRoot.GetComponentsInChildren<Collider>(true))
    c.enabled = false;

previewRenderers = previewRoot.GetComponentsInChildren<Renderer>(true);
if (previewMat != null)
    foreach (var r in previewRenderers) r.sharedMaterial = previewMat;

rangeIndicator = previewRoot.GetComponent<RangeIndicator>() ?? previewRoot.AddComponent<RangeIndicator>();
rangeIndicator.SetRadius(level.range);

isPlacing = true;
SetPreviewColor(invalidColor);
rangeIndicator.SetColor(invalidColor);
  • 타워 구매버튼을 누르면 타워 레벨 SO 에셋을 통해 프리팹을 생성한다.
  • 타워 프리뷰 오브젝트에 맞는 표면을 쏘는 Ray가 프리뷰에 걸리지 않게 프리팹의 Layer와 Collider를 꺼둔다.
  • RangeIndicator를 붙이고 초기 색상(붉은색)을 할당한다.
  • 추가로 배치 중일 때와 아닐 때를 구분하기 위해 배치 중에 카메라를 드래그 Pan 이동을 하지 못하도록 cameraController.SetUserPan(false);을 추가한다.

 

2) 포인터 읽기 (에디터, 모바일 공통)

  • New InputSystem을 사용해 Input Action Asset을 생성 후 마우스와 터치 Pan 이동에 필요한 Action 생성, Zoom에 필요한 마우스 휠 Action 생성 (터치를 통한 Zoom은 CameraController에 작성)
  • UI 위를 터치한 경우 타워 설치 확정만 막고 타워 프리뷰 이동은 계속 허용

 

3) 타워 브리뷰가 포인트를 계속해서 따라가되 표면의 단차 감지 및 정확한 이동

  • 프리뷰 이동과 설치 가능 여부를 분리
  • followSurfacemask : 프리뷰가 따라다닐 표면 ( 카메라 이동을 위한 환경 전체의 BoxCollider를 제외한 나머지 전체 LayerMask)
  • buildableMask : 설치 가능 표면
  • blockedMask : 설치 불가 표면

 

   로직 순서

bool overBuildable = TryGetBuildableAnchor(followPoint, out targetPos);
float smoothTime = overBuildable ? snapSmoothTime : unsnapSmoothTime;
previewRoot.position = Vector3.SmoothDamp(previewRoot.position, targetPos, ref smoothVel, smoothTime);
  1. 포인터 방향으로 RaycastAll(followSurfaceMask) -> 가장 가까운 히트점 선택 (나무, 낮은 단차 등 설치 불가 지점에도 타워 프리뷰는 포인터를 따라다님)
  2. 히트점을 기준으로 한번 더 다운 캐스트해 타워 프리뷰 오브젝트의 Y값 보정
  3. 설치 가능 표면 위일 경우 중앙에 부드럽게 스냅 이동, 아닐 경우 포인터 지점으로 부드럽게 이동

 

   Buildable 중앙 부드러운 스냅 이동

// probePoint 위→아래로 buildable만 체크
Physics.Raycast(probePoint + Vector3.up * 2f, Vector3.down, out hit, 6f, buildableMask)
  • 패드에 Anchor라는 자식 트랜스폼이 있을 경우 해당 위치로 스냅, 없으면 콜라이더 중심으로 스냅
  • 마지막에 Y는 실제 지면으로 다시 붙임
  • 패드 밖을 벗어나면 다시 포인터를 부드럽게 추적

 

4) 설치 가능 판정 & 색상 업데이트

설치 가능 판단은 두가지 조건 처리

// 타워 프리뷰 바로 아래가 buildable인가?
bool onBuildable = Physics.Raycast(p + Vector3.up * 0.5f, Vector3.down, out _, 2f, buildableMask);

// blocked와 겹치지 않는가?
bool noOverlap   = !Physics.CheckSphere(p + Vector3.up * 0.2f, footprintRadius, blockedMask);
isValid = onBuildable && noOverlap;

 

 

 

5) 엣지팬(Edge pan) : 화면 끝 근처에서 카메라 자동 이동

모바일 환경에서는 해상도, 노치, 제스처 바가 제각각이므로 픽셀값 대신 SafeArea 퍼센트를 사용했다.

Rect area = useSafeAreaForEdge ? Screen.safeArea : new Rect(0,0,Screen.width,Screen.height);
float edgeX = Mathf.Max(minEdgePx, area.width  * edgePctX);
float edgeY = Mathf.Max(minEdgePx, area.height * edgePctY);

float left = area.xMin + edgeX;  // 시작선
float right= area.xMax - edgeX;  // 시작선
// top/bottom도 동일

 

타워 프리뷰의 화면 좌표가 시작선 바깥으로 나가면 엣지팬을 점점 강하게 건다.

// 엣지팬 강도 = 선까지의 거리 비율
dx =  Pow( (sp.x - right) / edgeX, edgePanResponseExponent ) ...

 

실제 카메라 이동은 CameraController.PanByScreenDir(dir01, strength)에 위임한다.

 

 

 

CameraController : 팬, 줌, 경계 클램프

1) Cinemachine 연동

  • 씬에 CinemachineCamera(Unity 6.1 기준)을 배치하고 Tracking Target에 cameraTarget 트랜스폼을 인스펙터에서 할당한다.
  • CinemachinePositionComposer의 CameraDistance만 바꾸면 줌이 작동한다.
  • CinemachineConfiner3D를 통해 스테이지의 Root 오브젝트에 배치한 BoxCollider를 BoundingVolume으로 할당하면 이동 시 카메라가 콜라이더 밖으로 벗어나지 못한다.

2) 드래그 팬, 엣지팬 속도 분리

// 드래그 팬
move = (-delta.x * right + -delta.y * fwd) * dragPanSpeed * Time.deltaTime;

// 엣지팬
PanByScreenDir(dir01, strength):
  speed = edgePanSpeed * (옵션: 줌 스케일);
  cameraTarget.position += (right*dir01.x + fwd*dir01.y) * speed * strength * dt;
  • dragPanSpeed : 마우스, 한 손가락 드래그
  • edgePanSpeed :  엣지팬 전용
  • 타워 설치 중에는 UserPanEnabled = false로 드래그 팬 차단. 엣지팬은 계속 허용

 

3) 줌

speed *= Lerp(edgePanZoomScaleRange.x, edgePanZoomScaleRange.y, zoom01);
  • 마우스 휠 : Mouse.current.scroll.y -> composer.CameraDistance 조절
  • 모바일 핀치 : 활성 두 점 사이 거리 변화로 CameraDistance 조절

 

4) 맵 경계 클램프 + 디버그 기즈모

  • minBounds/maxBounds로 XZ를 Clamp
  • 에디터에서 맵의 경계를 확인하기 위한 기즈모 사각형과 컨텍스트 메뉴 추가

 

 

 

RangeIndicator

// RangeIndicator.cs
using UnityEngine;

[RequireComponent(typeof(LineRenderer))]
public class RangeIndicator : MonoBehaviour
{
    [SerializeField] int segments = 64;
    LineRenderer lr;

    void Awake()
    {
        lr = GetComponent<LineRenderer>();
        lr.loop = true;
        lr.useWorldSpace = false; // 로컬 원
        lr.positionCount = segments;
        lr.widthMultiplier = 0.05f;
    }

    public void SetRadius(float r)
    {
        for (int i = 0; i < segments; i++)
        {
            float t = (i / (float)segments) * Mathf.PI * 2f;
            lr.SetPosition(i, new Vector3(Mathf.Cos(t) * r, 0f, Mathf.Sin(t) * r));
        }
    }

    public void SetColor(Color c)
    {
        lr.startColor = lr.endColor = c;
    }
}
  • LineRenderer로 로컬 공간 원을 만듬
  • 반지름만 바꿔주면 레벨별 사거리 표시

 

 

 

이슈 해결

 

  • 프리뷰가 안 따라옴: followSurfaceMask가 너무 좁거나, 프리뷰가 Ray에 걸림 → 프리뷰 레이어를 Ignore Raycast로.
  • 패드 중앙이 이상: 패드에 Anchor 자식이 있는지, 없다면 콜라이더의 Bounds.center를 쓰는지 확인. 지형이 경사면이면 Y를 다시 지면으로 다운캐스트.
  • 모바일에서 엣지팬이 안 걸림:
    픽셀 기준이 아니라 safeArea 기반 퍼센트로 바꿔 해결. 노치/제스처바 영향 큼.
  • 엣지팬이 느림: CameraController.edgePanSpeed + mobileEdgePanStrengthMul 동시 조절

 

 

어려운 부분이 많았기 때문에 TowerPlacementController, CameraController 코드 블록별 정리 글을 한번 더 작성할 예정이다.

 

 

반응형