몬스터 순찰 영역 설계 (랜덤 순찰 ↔ 대기 ↔ 복귀)

목차

1. 요구 사항

2. 설계 목표

3. 흐름도

4. 구현

        4.1. 시작 위치와 순찰 지점 생성

       4.2. IDLE(대기) 상태

       4.3. MOVE(순찰 이동) 상태

       4.4. RETURN(복귀) 상태

       4.5. 복귀 완료 처리

5. 개발 의도

1. 시스템 요구 사항

몬스터는 플레이어를 인지하기 전까지 월드 내에서 자연스럽게 순찰 행동을 수행해야 한다.

이 순찰은 단순 정지 상태가 아니라 시작 위치를 기준으로 일정 반경 내에서 이동과 대기를 반복하는 구조여야 한다.

또한 플레이어가 인지 범위에 들어오면 순찰 루프를 즉시 중단하고 추적 상태로 전환해야 하며, 추적 또는 순찰 과정에서 몬스터가 설정된 활동 영역을 벗어나지 않도록 시작 위치(startPos)를 중심으로 영역을 보존하는 복귀 메커니즘이 필요하다.

이동은 NavMesh 기반으로 처리하여 장애물 회피와 경로 계산을 엔진에 위임하고, 도착 판정은 거리 비교 같은 임의 기준이 아니라 NavMeshAgent가 제공하는 경로 상태(pathPending, remainingDistance, stoppingDistance)를 근거로 결정되어야 한다.

마지막으로 상태 전이는 거리 조건(플레이어 인지)과 경로 완료 조건(도착)에 의해 명확히 결정되어야 하며, 순찰 상태가 전투 상태(CHASE)와 충돌해 반복 전이나 떨림이 생기지 않도록 전이 우선순위와 가드(조기 return)가 필요하다.

2. 설계  목표

- 시작 위치(startPos) 기반 활동 반경 유지

- 랜덤 순찰 지점 생성으로 정적 패턴 방지

- NavMeshAgent 경로 완료 판정 사용

- 플레이어 인지 전이를 순찰 루프보다 우선 처리

- RETURN 상태를 통해 영역 이탈 제어

3. 흐름도

Start

 ↓

state = MOVE

 ↓

SetRandomPatrolPoint()

 ↓

(도착) → IDLE (랜덤 지점)

 ↓

ReturnToStart()

 ↓

(도착) → IDLE (시작 위치)

 ↓

SetRandomPatrolPoint()

 ↓

반복

몬스터의 순찰은 '랜덤 위치 → 대기 → 시작 위치 복귀 → 대기 → 랜덤 위치' 반복 구조이다.

이 구조는 몬스터가 시간이 지나도 시작 위치 중심을 유지하도록 보장한다.

4. 구현

4.1. 시작 위치와 순찰 지점 생성
protected Vector3 startPos;
protected Vector3 patrolPos;

protected void SetRandomPatrolPoint()
{
    patrolPos = startPos + new Vector3(
        Random.Range(-5f, 5f),
        0,
        Random.Range(-5f, 5f)
    );

    agent.isStopped = false;
    agent.speed = speed;
    agent.SetDestination(patrolPos);

    state = State.MOVE;
    Animation(false, true, false, false);
}

이 구간의 핵심은 랜덤을 현재 위치가 아니라 startPos 기준으로 만든다는 점이다.

처음 구현에서는 ‘현재 위치 + 랜덤 오프셋’으로 순찰 지점을 만들었다.

이때, 몬스터가 랜덤을 반복할수록 기준점이 계속 변경되어, 활동 반경이 서서히 커져갔다.

지금은 startPos를 고정 기준으로 삼아, 항상 동일한 원점 주변에서만 랜덤 목표가 생성되므로 시간이 지나도 영역이 커지지 않는다.

startPos는 몬스터가 생성된 최초 위치를 저장하는 기준점이다.

Random.Range(-5f, 5f)는 Unity의 난수 API로, 부동소수 범위에서 균일 분포 값을 뽑아 순찰 패턴이 고정되지 않도록 한다.

y를 0으로 고정한 이유는 NavMesh가 일반적으로 지면 평면에서 경로를 계산하기 때문에, 수직 오프셋이 끼면 경로가 실패하거나 의도치 않은 위치로 설정될 수 있기 때문이다.

agent.isStopped = false는 NavMeshAgent의 내부 이동 업데이트를 재개시키는 스위치 역할을 하며, 이전 상태에서 공격/대기 등으로 멈춰있던 에이전트가 다시 목적지로 움직이도록 보장한다.

agent.speed = speed는 '몬스터 데이터(스탯) → 이동 실행(Agent)'로 값을 전달하는 지점이며, 베이스 클래스에서 소유한 speed를 실행 계층이 소비해 실제 이동 속도로 반영한다.

SetDestination은 목표 지점을 설정함과 동시에 NavMesh 상에서 경로를 계산하고 추적하도록 엔진에게 위임하는 호출인데, 중요한 점은 이 호출이 끝나는 순간 MOVE 상태의 의미가 확정된다는 것이다.

즉 MOVE는 실제로 얼마나 이동했는가가 아니라 현재 목표를 향해 이동하도록 경로가 걸린 상태로 정의되고, 그 정의가 상태 머신의 일관성을 만든다.

4.2. IDLE(대기) 상태
protected float idleTimer;
protected float idleDuration = 3f;
protected bool isReturningToStart = false;

protected virtual void UpdateIdle(float dist)
{
    if (dist <= chaseDist)
    {
        state = State.CHASE;
        return;
    }

    idleTimer -= Time.deltaTime;

    if (idleTimer <= 0f)
    {
        if (isReturningToStart)
        {
            isReturningToStart = false;
            SetRandomPatrolPoint();
        }
        else
        {
            ReturnToStart();
        }
    }
}

IDLE은 단순히 멈춰 있는 상태가 아니라 다음 이동을 결정하는 제어 상태다.

가장 먼저 dist <= chaseDist를 검사하고 즉시 state = CHASE로 바꾸며 return하여, 순찰 루프보다 전투 전이를 우선시한다.

AI는 항상 플레이어 반응을 최우선으로 둔다.

이 우선순위가 없으면 IDLE 타이머 종료와 CHASE 전이가 한 프레임에서 엉켜 상태가 흔들릴 수 있는데, 조기 return은 그 충돌을 끊는 가드 역할을 한다.

IDLE 상태는 반드시 MOVE 또는 RETURN 상태에서 도착 판정을 거친 후 진입하며, 그 시점에 idleTimer = idleDuration으로 초기화된다.

따라서 IDLE에 들어왔을 때 idleTimer가 0인 채로 즉시 다음 상태로 넘어가는 상황은 구조적으로 발생하지 않는다.

이는 상태 진입 시점에 타이머 초기화를 강제하여 대기 시간이 항상 보장되도록 만든 설계다.

idleTimer -= Time.deltaTime는 프레임 독립 시간 제어 방식으로, FPS가 높아지거나 낮아져도 실제 대기 시간이 일정하게 유지된다.

대기 시간은 물리 시간이 아니라 게임 플레이 흐름에 종속되는 값이기 때문에, FixedUpdate(물리 틱)에 맞출 필요가 없고, 상태 소비 루프(Update)에서 일관되게 처리하는 편이 자연스럽다.

idleTimer가 0 이하가 되면 다시 순찰 지점을 생성한다.

isReturningToStart는 현재 대기 상태가 랜덤 지점 대기인지, 시작 위치 대기인지를 구분하는 플래그다.

이 변수는 순찰 루프의 방향성을 제어한다.

isReturningToStart 플래그에 따라 다음 행동이 결정된다.

현재 랜덤 지점에서 대기한 경우에는, ReturnToStart를 호출하여 시작 위치로 복귀한다.

시작 위치에서 대기한 경우에는, SetRandomPatrolPoint를 호출하여 다시 랜덤 지점으로 이동한다.

이 구조 덕분에 순찰은 항상 '랜덤 위치 ↔ 시작 위치' 의 패턴이 반복된다.

이 방식이 중요한 이유는 복귀를 이벤트처럼 한 번 수행하는 것이 아니라, 순찰 루프의 일부로 편입시켜, 시간이 지나도 항상 startPos를 기준으로 활동하게 만드는 구조적 제약을 만들기 때문이다.

4.3. MOVE(순찰 이동) 상태
protected void UpdatePatrol(float dist)
{
    if (dist <= chaseDist)
    {
        state = State.CHASE;
        return;
    }

    if (!agent.pathPending &&
        agent.remainingDistance <= agent.stoppingDistance)
    {
        agent.isStopped = true;
        isReturningToStart = false;

        state = State.IDLE;
        idleTimer = idleDuration;
        Animation(true, false, false, false);
    }
}

MOVE 상태에서 가장 중요한 것은 도착 판정의 기준이다.

agent.remainingDistance는 현재 경로 상 목표 지점까지 남은 거리를 의미하고, stoppingDistance는 NavMeshAgent가 목표에 도달했다고 간주할 허용 오차(정지 반경)다.

이 둘을 비교하면 정확히 목표 좌표에 일치가 아니라 에이전트가 멈추기에 충분히 근접했는지를 판정할 수 있어, 경로 오차나 감속 때문에 도착 처리가 영원히 안 나는 문제를 피할 수 있다.

다만 remainingDistance는 경로 계산이 완료되기 전(pathPending)에는 신뢰하기 어렵기 때문에, !agent.pathPending을 같이 검사해 경로가 아직 준비 중인데 도착 처리해버리는 버그를 막는다.

도착이 확인되면 agent.isStopped = true로 이동 업데이트를 멈추고, state = IDLE로 전환하면서 idleTimer = idleDuration을 세팅해 다음 루프가 즉시 실행되지 않도록 대기 시간을 확정한다.

여기서 isReturningToStart = false를 찍는 이유는 지금 도착한 지점은 랜덤 지점이라는 의미를 상태로 남기기 위해서인데, 이 한 줄이 없으면 IDLE에서 다음 행동(복귀/재순찰) 방향이 꼬일 수 있다.

마지막으로 Animation(...)은 로직 상태 전환과 시각 표현을 동기화하는 호출로, 순찰 이동이 끝나 멈췄다는 사실을 애니메이터 파라미터에 반영해 플레이어가 상태 변화를 즉시 이해하게 만든다.

4.4. RETURN(복귀) 상태
protected void ReturnToStart()
{
    state = State.RETURN;

    agent.isStopped = false;
    agent.speed = speed * 2f;
    agent.SetDestination(startPos);

    Animation(false, false, true, false);
}

RETURN은 영역 보존의 핵심 상태이며, MOVE와 본질적으로 동일하게 목표를 startPos로 두고 NavMeshAgent에게 이동을 위임하는 상태다.

여기서 state를 먼저 RETURN으로 바꾸는 이유는, 현재 목적이 복귀라는 의미를 명확히 하고, 이후 Update에서 RETURN 전용 도착 처리(UpdateReturn)가 실행되도록 라우팅하기 위해서다.

agent.isStopped = false는 복귀 명령이 이전 상태의 정지(공격/대기)에 막히지 않도록 하는 안전 장치다.

agent.speed = speed * 2f는 설계 의도가 직접 반영된 부분으로, 복귀는 전투/순찰에서 정상 이동이 아니라 리셋 동작에 가깝기 때문에, 빠르게 원점으로 돌아와 월드가 어지럽게 확장되지 않게 한다.

4.5. 복귀 완료 처리
protected virtual void UpdateReturn()
{
    if (!agent.pathPending &&
        agent.remainingDistance <= agent.stoppingDistance)
    {
        agent.isStopped = true;

        transform.position = startPos;

        state = State.IDLE;
        idleTimer = idleDuration;
        Animation(true, false, false, false);

        isReturningToStart = true;
    }
}

도착이 확인되면 agent.isStopped = true로 이동 업데이트를 중단하고, transform.position = startPos로 좌표를 강제로 보정한다.

transform.position = startPos는 agent.isStopped = true 상태에서, 도착 판정 직후 한 번만 수행되며 이후 즉시 IDLE로 전환되므로, NavMeshAgent 내부 경로 상태와의 충돌 가능성이 낮다고 판단했다.

다만 더 안전한 방법으로는 agent.Warp(startPos)를 사용할 수 있으며, 대규모 프로젝트가 된다면 Warp로 대안할 계획이다.

이후 state = IDLE, idleTimer = idleDuration으로 루프를 다시 대기 상태로 돌려 순찰 패턴이 즉시 재개되지 않게 한다.

isReturningToStart = true로, 시작 위치에서의 대기임을 표시함으로써, 다음 IDLE 종료 시 SetRandomPatrolPoint로 자연스럽게 재순찰이 이어지게 만든다.

이 한 줄이 없으면 IDLE에서 또 ReturnToStart가 호출되어 시작 위치에서 계속 복귀만 시도하는 루프가 생길 수 있으므로, 루프 방향을 고정하는 핵심 플래그다.

* 결과

플레이어가 인지되지 않을때, 시작 위치 ↔ 랜덤 위치 반복 순찰

5. 개발 의도

초기 순찰 구조는 랜덤 지점 간 이동을 반복하는 방식이었지만, 이 방식은 랜덤의 기준이 현재 위치로 누적되면 시간이 지날수록 시작 위치에서 멀어질 수 있다는 구조적 리스크가 있었다.

그래서 순찰의 기준점을 startPos로 고정하고, 랜덤 순찰과 시작 위치 복귀를 한 루프로 묶어 활동 반경이 절대 확장되지 않도록 설계했다.

'순찰 ↔ 대기 ↔ 복귀' 는 NavMeshAgent의 경로 상태를 근거로 도착을 판정해 신뢰성을 확보했고, 플레이어 인지 전이는 어떤 상태에서든 최우선으로 처리하도록 조기 return 가드를 배치해 순찰과 전투가 충돌하지 않게 했다.

결과적으로 이 순찰 시스템은 시간이 지나도 월드 내 AI가 흐트러지지 않고, 전투 상태 머신과 결합하더라도 상태 전이가 안정적으로 유지되는 기반 루프로 기능한다.