Click-to-Move 구현하기: NavMesh 기반 장애물 회피와 타겟 자동 접근 및 스킬 발동
마우스 클릭 위치를 월드 좌표로 변환하고 NavMesh로 장애물을 회피하면서 적을 클릭하면 사거리까지 자동으로 접근해 스킬을 발동하는 Click-to-Move 시스템의 구조와 구현 방법을 정리합니다.

Click-to-Move 메커니즘 개요
쿼터뷰나 탑다운 방식으로 조작하는 액션 RPG와 RTS에서는 마우스 클릭만으로 캐릭터를 이동시키고 전투까지 이어 가는 Click-to-Move 시스템을 자주 사용합니다. 겉보기에는 간단하지만 실제로는 화면 좌표를 월드 좌표로 바꾸는 과정부터 길찾기, 타겟 추적, 스킬 사거리 판정, 상태 전환까지 여러 기능이 맞물려 있습니다.
이 글에서는 플레이어가 지형을 클릭했을 때와 적을 클릭했을 때를 나누어 처리하는 기본 흐름을 살펴봅니다. 예시는 Unity의 NavMeshAgent를 기준으로 작성했습니다.
flowchart TD
A[마우스 클릭 발생] --> B[Camera Raycast 수행]
B --> C{피격 레이어 검출}
C -->|지형 Layer| D[지형 좌표로 NavMesh 이동]
C -->|적 Layer| E[적 Target 지정 및 추적]
D --> F[목적지 도착 시 Idle 정지]
E --> G{남은 거리 vs 스킬 사거리}
G -->|사거리 미달| H[적 위치로 이동 지속 갱신]
G -->|사거리 진입| I[이동 정지 및 회전 후 스킬 발동]
1. 화면 좌표를 3D 월드 좌표로 변환하기 (Raycasting)
마우스 포인터의 위치는 화면 좌표이므로 그대로는 게임 월드 안의 위치로 사용할 수 없습니다. 먼저 카메라에서 마우스 위치를 향하는 레이(Ray)를 만들고 이 레이가 어떤 콜라이더와 충돌했는지 확인해야 합니다.
ScreenPointToRay로 레이를 만든 뒤 레이어 마스크(Layer Mask)를 적용하면 클릭한 대상이 이동 가능한 지형인지 공격 대상인 적인지 구분할 수 있습니다.
아래 예시는 Camera.ScreenPointToRay와 Physics.Raycast의 Unity 6 API를 사용합니다. (ScreenPointToRay, Physics.Raycast)

지형과 적을 별도의 물리 레이어로 나누어 두면 입력 처리도 명확해집니다. 예를 들어 지형에는 Ground, 적 오브젝트에는 Enemy 레이어를 지정하고 적 레이어를 먼저 검사하면 적을 클릭했을 때 일반 지형 이동으로 처리되는 문제를 피할 수 있습니다.
2. 지형 장애물 회피와 NavMesh 길찾기
클릭한 위치까지 캐릭터를 직선으로 이동시키면 벽이나 구조물에 막힐 수 있습니다. 장애물이 있는 맵에서는 목적지까지 이동 가능한 경로를 계산하고 그 경로를 따라 캐릭터를 움직여야 합니다.
Unity의 베이크된 NavMesh와 에이전트 설정은 이동 가능한 영역과 경로 계산에 사용됩니다. 지형을 클릭하면 NavMeshAgent의 목적지를 갱신하고 에이전트가 계산된 경로를 따라 이동하도록 할 수 있습니다. 목적지 설정은 비동기 경로 계산을 시작할 수 있어 pathPending을 확인해야 합니다. (Unity NavMesh 및 NavMeshAgent API)
다만 레이캐스트가 검출한 위치가 항상 NavMesh 위에 있다는 보장은 없습니다. NavMesh.SamplePosition으로 지정한 범위 안에서 NavMesh 위치를 찾을 수 있지만, 이 API는 월드 장애물을 검사하지 않으므로 다층 맵에서는 클릭한 지점과 다른 층의 NavMesh가 반환될 수 있습니다. 샘플링 결과가 클릭 의도와 같은 층인지 별도로 확인하고, 실패하면 클릭을 무시하거나 제한된 범위에서 목적지를 보정합니다. (NavMesh.SamplePosition)
3. 타겟 추적과 거리 계산
적을 클릭했을 때는 목적지가 특정 좌표로 고정되지 않습니다. 적이 움직일 수 있기 때문에 캐릭터는 타겟의 현재 위치를 따라가면서 스킬 사거리 안에 들어왔는지 계속 확인해야 합니다.
캐릭터의 위치를 , 적의 위치를 라고 하면 지면을 기준으로 한 수평 거리는 다음과 같이 계산할 수 있습니다.
스킬 사거리를 이라고 할 때, 캐릭터가 이동을 멈추고 스킬을 사용할 수 있는 조건은 다음과 같습니다.
실제 게임에서는 매번 제곱근을 계산하는 대신 제곱된 거리를 비교해도 같은 결과를 얻을 수 있습니다. 또한 캐릭터와 적의 높이가 다를 수 있으므로 이 글의 예시처럼 XZ 평면의 거리만 비교할지 3차원 거리를 사용할지 게임 규칙에 맞게 정해야 합니다.
타겟이 계속 움직이는 상황이라면 매 프레임 목적지를 다시 설정할 필요는 없습니다. 예를 들어 0.1초마다 갱신하거나 타겟의 위치가 일정 거리 이상 바뀌었을 때만 SetDestination을 호출하면 불필요한 경로 재계산을 줄일 수 있습니다.

4. 스킬 사거리 진입 시 자동 발동 및 C# 구현
타겟을 추적하다가 사거리 조건을 만족하면 에이전트의 이동을 멈춥니다. 그다음 캐릭터가 적을 바라보도록 회전시키고 스킬 애니메이션과 실제 공격 판정을 실행합니다.
아래 코드는 Click-to-Move와 타겟 접근, 사거리 진입 후 스킬 발동까지의 흐름을 보여주는 간단한 예시입니다. 클릭 위치가 NavMesh 위에 있는지 확인하고 움직이는 타겟의 경로는 일정한 간격으로만 갱신하도록 구성했습니다. 실제 프로젝트에서는 애니메이션 이벤트, 쿨다운, 공격 판정, 타겟 사망 처리 등을 별도 시스템으로 분리하는 편이 관리하기 좋습니다.
이 코드는 레거시 Input Manager의 Input.GetMouseButtonDown을 사용합니다. 새 Input System 프로젝트에서는 클릭 입력 부분을 Input Actions로 교체해야 합니다.
using UnityEngine;
using UnityEngine.AI;
public enum PlayerState
{
Idle,
Moving,
ApproachingTarget,
CastingSkill
}
public class ClickToMoveController : MonoBehaviour
{
[Header("Components")]
[SerializeField] private NavMeshAgent agent;
[SerializeField] private Camera mainCamera;
[Header("Settings")]
[SerializeField] private LayerMask groundLayer;
[SerializeField] private LayerMask enemyLayer;
[SerializeField] private float skillRange = 3.0f;
[SerializeField] private float rotationSpeed = 10.0f;
[SerializeField] private float pathUpdateInterval = 0.1f;
private PlayerState currentState = PlayerState.Idle;
private Transform targetEnemy;
private float nextPathUpdateTime;
private void Update()
{
HandleInput();
UpdateState();
}
private void HandleInput()
{
if (!Input.GetMouseButtonDown(0)) return;
Ray ray = mainCamera.ScreenPointToRay(Input.mousePosition);
// 1. 적 레이어 우선 검출
if (Physics.Raycast(ray, out RaycastHit enemyHit, 100f, enemyLayer))
{
targetEnemy = enemyHit.transform;
currentState = PlayerState.ApproachingTarget;
agent.isStopped = false;
return;
}
// 2. 지형 레이어 검출
if (Physics.Raycast(ray, out RaycastHit groundHit, 100f, groundLayer))
{
// 레이캐스트 위치가 NavMesh 위에 없을 수 있으므로 주변에서 보정 위치를 찾는다.
if (NavMesh.SamplePosition(groundHit.point, out NavMeshHit navHit, 2.0f, NavMesh.AllAreas))
{
targetEnemy = null;
agent.isStopped = false;
if (agent.SetDestination(navHit.position))
{
currentState = PlayerState.Moving;
}
}
}
}
private void UpdateState()
{
switch (currentState)
{
case PlayerState.Moving:
if (!agent.pathPending && agent.remainingDistance <= agent.stoppingDistance)
{
currentState = PlayerState.Idle;
}
break;
case PlayerState.ApproachingTarget:
if (targetEnemy == null || !targetEnemy.gameObject.activeInHierarchy)
{
currentState = PlayerState.Idle;
agent.isStopped = true;
return;
}
Vector3 offset = targetEnemy.position - transform.position;
offset.y = 0f;
float distance = offset.magnitude;
if (distance <= skillRange)
{
if (offset.sqrMagnitude > 0.0001f)
{
transform.rotation = Quaternion.LookRotation(offset);
}
agent.isStopped = true;
currentState = PlayerState.CastingSkill;
ExecuteSkill();
}
else if (Time.time >= nextPathUpdateTime)
{
if (NavMesh.SamplePosition(targetEnemy.position, out NavMeshHit navHit, 2.0f, NavMesh.AllAreas))
{
agent.SetDestination(navHit.position);
}
nextPathUpdateTime = Time.time + pathUpdateInterval;
}
break;
case PlayerState.CastingSkill:
if (targetEnemy == null || !targetEnemy.gameObject.activeInHierarchy)
{
CompleteSkill();
break;
}
if (targetEnemy != null)
{
Vector3 direction = (targetEnemy.position - transform.position).normalized;
direction.y = 0;
if (direction != Vector3.zero)
{
Quaternion lookRotation = Quaternion.LookRotation(direction);
transform.rotation = Quaternion.Slerp(transform.rotation, lookRotation, Time.deltaTime * rotationSpeed);
}
}
break;
}
}
private void ExecuteSkill()
{
Debug.Log("스킬 발동 로직 실행");
// 실제 프로젝트에서는 애니메이션 이벤트에서 공격 판정을 실행한다.
Invoke(nameof(CompleteSkill), 1.0f);
}
private void CompleteSkill()
{
CancelInvoke(nameof(CompleteSkill));
targetEnemy = null;
agent.isStopped = true;
currentState = PlayerState.Idle;
}
}
5. 실무에서 추가로 고려할 사항
1) 피격 상자 크기 보정 (Bounding Box Offset)
두 오브젝트의 중심점만 비교하면 타겟의 크기를 거리 계산에 반영할 수 없습니다. 큰 몬스터를 상대할 때는 실제로는 공격 가능한 위치에 도착했는데도 캐릭터가 타겟의 중심까지 파고드는 현상이 나타날 수 있습니다.
이 문제를 줄이려면 타겟의 Collider 반지름이나 바운딩 볼륨을 이용해 유효 사거리를 보정해야 합니다. 캐릭터와 타겟의 콜라이더 크기, 공격 스킬의 판정 범위에 따라 보정값을 다르게 둡니다. 단순히 중심점 거리만 사용하는 대신 Collider.ClosestPoint로 콜라이더 표면의 가장 가까운 점을 구할 수도 있습니다. (Unity Collider.ClosestPoint)
2) 타겟 소멸 시 예외 처리
이동 중 타겟이 파괴되거나 비활성화되면 targetEnemy를 더 이상 사용할 수 없습니다. 이 상태를 처리하지 않으면 에이전트가 존재하지 않는 대상을 계속 추적하거나 상태 머신이 ApproachingTarget에 머무를 수 있습니다. 예제에서는 매 프레임 참조와 활성화 상태를 확인한 뒤, 문제가 있으면 Idle로 돌아가도록 처리했습니다.
타겟을 클릭할 때 적의 자식 콜라이더가 검출되는 경우도 있습니다. 이때는 적 루트에 타겟 컴포넌트를 두고 GetComponentInParent<Targetable>()처럼 부모 방향으로 실제 타겟을 찾으면 부위별 콜라이더가 있는 적도 하나의 대상으로 관리할 수 있습니다. 타겟이 사망했을 때 다음 공격 대상을 자동으로 선택하는 기능도 같은 지점에서 연결할 수 있습니다.
3) 경로 갱신 빈도 최적화 (Path Throttling)
움직이는 타겟의 위치가 조금 바뀔 때마다 매 프레임 SetDestination을 호출하면 NavMesh 경로 계산이 불필요하게 반복됩니다. 타겟의 위치 변화량이 일정 기준을 넘었을 때만 갱신하거나 일정한 주기를 두고 갱신하는 방식이 더 안정적입니다. 예제에서는 pathUpdateInterval을 사용해 0.1초마다 한 번씩 목적지를 갱신합니다.
경로 갱신 주기는 캐릭터의 이동 속도와 타겟의 방향 전환 빈도에 따라 조절해야 합니다. 너무 길면 타겟을 늦게 따라가고 너무 짧으면 경로 계산 비용이 커집니다. 대상의 위치가 일정 거리 이상 바뀌었을 때만 갱신하는 조건을 주기와 함께 사용하면 상황에 맞게 비용을 더 줄일 수 있습니다.
4) 스킬 애니메이션과 실제 판정 분리
예제에서는 흐름을 단순하게 보여주기 위해 ExecuteSkill에서 1초 뒤 Idle 상태로 돌아갑니다. 실제 공격 판정은 스킬을 호출한 순간이 아니라 공격 애니메이션의 타격 프레임에 맞춰 실행하는 편이 자연스럽습니다. Animation Event나 타임라인 이벤트에서 피해 판정을 호출하고 애니메이션이 끝났을 때 상태를 종료하면 시각 효과와 게임 판정의 타이밍을 맞출 수 있습니다.
스킬 쿨다운, 시전 취소, 피격에 의한 경직처럼 예외 상황이 많아지면 상태 머신 안에 모든 로직을 넣기보다 이동, 타겟팅, 스킬 시스템을 분리하는 것이 좋습니다.



