포스트

개발일지 #04 - 적 FSM/AI, 서버 권한, 스포너/컨텍스트

적 FSM/AI 구축, 서버 권한 전환, 스포너, 컨텍스트 도입

개발일지 #04 - 적 FSM/AI, 서버 권한, 스포너/컨텍스트

오늘의 작업 내용

  • 적 FSM 구축: Idle / Patrol / Chasing / Dead 상태 컴포넌트화 및 등록
  • SyncEnum<E_EnemyState> 적용으로 서버 권한 기반 상태 전환 동기화
  • EnemyControllerEnemyContext로 의존성 표준화, 서버 전용 Tick 구성
  • Spawner로 서버 주기 스폰 구현(UniTask 사용)
  • PlayerContextEnemyContext 도입으로 의존성 표준화

구현 배경

  • 2일차에 만든 SyncEnum<TEnum>을 플레이어에 이어 적 AI에도 적용해 상태 중심 아키텍처를 확장.
  • 3일차의 FSM/애니메이션 동기화 패턴을 그대로 적에도 재사용해 일관성확장성 확보.
  • 서버 권한 모델을 고수해 판단/상태 전환을 서버에서만 수행, 클라이언트는 관측과 재생산에 집중.

설계/구현 요점

1) EnemyFSM + SyncEnum

  • 상태 수집을 GetComponentsInChildren<...>()로 표준화하고 딕셔너리 등록.
  • 서버에서만 _syncEnemyState.ServerSetValue(next)로 값 변경. 비서버는 ServerRpc로 요청.
  • 서버에서만 Tick/FixedTick 수행해 AI 판단의 단일 권한 보장.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 상태 전환(서버 권한)
public void SetState(E_EnemyState nextState)
{
    if (IsServerInitialized)
    {
        _stateDict[CurState].ExitState();
        _syncEnemyState.ServerSetValue(nextState);
        _stateDict[nextState].EnterState();
    }
    else
    {
        _stateDict[CurState].ExitState();
        RPC_RequestSetState(nextState);
        _stateDict[nextState].EnterState();
    }
}

[ServerRpc]
private void RPC_RequestSetState(E_EnemyState nextState)
{
    _syncEnemyState.ServerSetValue(nextState);
}

플레이어 FSM과 구조 유사점/차이점(서버 전용 실행)

EnemyFSM은 플레이어 FSM과 비슷하게 상태를 컴포넌트로 쪼개서 딕셔너리에 등록했고, 전환은 항상 Exit -> Set -> Enter 순서를 지켰다. 상태 동기화는 SyncEnum<T>로 처리했다.

다만 적 FSM은 설계 철학을 다르게 가져가서, 주요 로직을 전부 서버에서 돌아가게했다.

Tick/FixedTick, 플레이어 탐지, 순찰/추격 판단 같은 결정은 서버에서만 돌리고, 클라이언트는 동기화된 상태 값과 애니메이션만 받아서 재생한다.

클라이언트 공격에 의한 상태 변경(예상 설계)

앞으로 클라이언트의 공격으로 적 상태가 바뀌어야 할 때는, 클라가 적중을 확정하지 않고 서버에 보고만 하도록 설계를 진행할것 같다.

서버에서 히트박스 교차, 레이캐스트, 지연 보정으로 유효성을 검증한 뒤 데미지를 적용. 체력이 임계치면 EnemyFSM을 Dead로 넘기고, 아니라면 상황에 따라 일시 경직(Hit/Stagger, 추후 추가)을 쓰거나 Chasing을 복귀.

핵심은 상태 변경의 최종 권한은 항상 서버에 있고, 클라는 ‘요청’과 ‘재생’에 집중한다는 점이다.

2) EnemyController + EnemyContext / Context 도입(Enemy/Player)

4일차에는 EnemyContextPlayerContext를 새로 도입했다. 그전까지는 FSM이나 상태에서 필요한 컴포넌트를 직접 찾는 방식이었는데, 컨텍스트로 묶어두니 초기화와 검증이 한 번에 끝나고, 상태 쪽에서는 오너와 컨텍스트만 받아서 의존성을 깔끔하게 쓸 수 있었다. 결과적으로 상태 코드가 더 읽기 쉬워졌고, 네트워크 애니메이터나 스탯처럼 공통으로 참조하는 것들의 접근이 일관됐다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// PlayerContext 예시
[System.Serializable]
public class PlayerContext
{
    [field: SerializeField] public CharacterController Controller { get; private set; }
    [field: SerializeField] public NetworkAnimator NetAnim { get; private set; }
    [field: SerializeField] public PlayerFSM FSM { get; private set; }
    [field: SerializeField] public PlayerStats Stat { get; private set; }

    public void OnInit(PlayerController owner)
    {
        ...
    }

    public bool ValidateComponents()
    {
        ...
    } 
}
1
2
3
4
5
public override void EnterState()
{
    if (_context.ValidateComponents() == false)
        return;
}
  • 컨텍스트에 Rigidbody / NetworkAnimator / CapsuleCollider / EnemyFSM / EnemyStats / UI_Enemy를 묶고 ValidateComponents()로 필수성 보장.
  • OnStartServer()에서만 초기화/초기 상태 전환을 수행하고, Update/FixedUpdate도 서버에서만 Tick.
1
2
3
4
5
6
7
8
9
10
11
public override void OnStartServer()
{
    if (_context.ValidateComponents() == false)
    {
        return;
    }

    _isInit = true;
    _context.FSM.OnInit(this, _context);
    _context.FSM.SetState(E_EnemyState.Idle);
}

3) 상태별 핵심 로직

  • Idle: 플레이어 탐지 시 Chasing, 일정 시간 경과 시 Patrol로 전환.
  • Patrol: 랜덤 워크 기반 목표점 이동, 탐지 시 Chasing으로 전환.
  • Chasing: 탐지 범위 내 추격 이동, 범위 이탈 시 Patrol로 복귀.
  • Dead: 후속 구현을 위한 자리만 확보.

4) 임시 Spawner - 서버전용

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public override void OnStartServer()
{
    SpawnEnemy().Forget();
}

private async UniTask SpawnEnemy()
{
    while (true)
    {
        await UniTask.WaitForSeconds(_spawnInterval);
        var spawnPoint = _spawnPoints[Random.Range(0, _spawnPoints.Length)];
        var enemy = Instantiate(_enemyPrefab, spawnPoint.position, spawnPoint.rotation);
        ServerManager.Spawn(enemy);
    }
}

테스트 결과

  • Host-Client 환경에서 Idle/Patrol/Chasing 전환과 애니메이션 Speed 재생산이 정상 동작.
  • 스포너가 서버에서만 작동하며, 새로 스폰된 적도 FSM 초기화와 상태 동기화가 즉시 적용됨.
  • 탐지 범위 진입/이탈에 따른 추격/순찰 전환 안정적.

문제 해결 메모

  • 서버 전용 Tick을 강제하지 않으면 클라이언트에서도 AI가 실행돼 권한 충돌 발생. IsServerInitialized 점검으로 해소.
  • 추격 종료 시 관성으로 미세 이동이 남아 보여서 Chasing.Exit에서 Rigidbody 속도를 초기화.
  • 애니메이터 Speed를 상태 로직과 분리해 매 프레임 갱신함으로써 전환 구간에서도 시각적 부자연스러움을 최소화.

다음 단계

  • 무기 파이프라인 기반 확장: 근접/원거리 타이밍·히트 훅 설계
  • 장착 로직 서버 권한화: 클라 입력 -> 서버 검증 -> 적용 동기화
  • 무기 스탯 정의 및 FSM 연동(공격 속도·사거리 등)
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.