
개요
이번엔 타워 배치 시스템을 구현해보았다.
먼저 전체적인 로직 흐름은 다음과 같다.
타워 선택 -> 타워 상세정보 표시 -> 타워 구매버튼 클릭 -> 배치 가능한 지형 위로 드래그 -> 배치 가능 지형에 배치 확정 시 스테이지 포인트 차감
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);
- 포인터 방향으로 RaycastAll(followSurfaceMask) -> 가장 가까운 히트점 선택 (나무, 낮은 단차 등 설치 불가 지점에도 타워 프리뷰는 포인터를 따라다님)
- 히트점을 기준으로 한번 더 다운 캐스트해 타워 프리뷰 오브젝트의 Y값 보정
- 설치 가능 표면 위일 경우 중앙에 부드럽게 스냅 이동, 아닐 경우 포인터 지점으로 부드럽게 이동
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 코드 블록별 정리 글을 한번 더 작성할 예정이다.
'개발 > Unity 3D' 카테고리의 다른 글
| [Unity] 상속과 중재자 패턴, 옵저버 패턴으로 UI 로직 리팩토링 하기 (1) | 2025.09.14 |
|---|---|
| [Unity] 타워 디펜스 스킬 시스템 구현(스킬 사용 및 쿨다운, 강화 UI 연동) (0) | 2025.09.02 |
| [Unity] MVC 관점에서의 책임 분리와 이벤트 기반 통신 흐름 (5) | 2025.07.28 |
| [Unity] Layer Collision Matrix - 투사체 충돌 통과 & 충돌 로직 (1) | 2025.07.21 |
| [Unity] 이동하는 몬스터의 체력바가 항상 카메라를 바라보도록 하기 (1) | 2025.07.17 |