BulletAnt 개발일지 (3) - StateTree+GAS

김펭귄·2026년 5월 14일

Today What I Learned (TIL)

목록 보기
118/139

StateTree + GAS 기반 상태 관리 구조

AI 상태 관리는 StateTree가 담당하고,
GAS는 상태 표현 및 Gameplay 실행만 담당하도록 역할을 분리했다.

현재 흐름은 다음과 같다.

  • 게임 시작 시 StateTree가 Move State 진입
  • MoveToTargetActorTask 실행
  • Task 내부에서 Enemy에게 GameplayEffect(GE) 적용
  • GE를 통해 State.Move 태그 부여
  • 이동 완료 시 GE 제거를 통한 State.Move 태그 제거
  • Attack State로 전환

핵심은 GameplayTag를 직접 관리하지 않고,
GameplayEffect를 통해 부여한 점이다.

MoveGEHandle = ApplyGameplayEffectToSelf(MoveStateGE)

이렇게 하면 GE가 유지되는 동안만 이동 태그가 활성화된다.

이후 이동 종료 시:

RemoveActiveGameplayEffect(MoveGEHandle)

처럼 GE만 제거하면 연결된 GameplayTag도 자동으로 제거된다.

덕분에:

  • 상태 종료 시 Tag 제거 누락 방지
  • 상태와 Tag 생명주기 일치
  • 디버깅 단순화

같은 장점을 얻을 수 있었다.

결과적으로:

StateTree는 상태를 결정하고,
GAS는 상태를 표현한다

는 구조로 정리했다.


GameplayEffect에서의 태그 부여 문제점

이동과 마찬가지로 공격 상태를 표현하기 위해 먼저 GE_Attack을 적용하고, GE에서 공격 태그(State.Combat)를 부여하는 방식으로 구성했었다.

하지만 구조를 정리하면서 이 방식이 GAS의 의도와 맞지 않는다는 걸 알게 되었다.

GameplayEffect는 기본적으로:

  • 체력/마나 변화
  • 버프/디버프
  • 일정 시간 유지되는 상태
  • 특정 태그를 신호로 사용하는 트리거

같은 역할에 더 가깝다.

예를 들어:

  • 3초 동안 독 데미지 적용
  • 화상 상태 유지
  • Instant GE로 특정 Ability 트리거

처럼 사용하는 것이 일반적인 흐름이었다.

반면 내가 원했던 건:

“GA_Attack이 실행되는 동안만 공격 태그를 유지”

하는 것이었다.

이 경우에는 GE보다 Activation Owned Tags가 더 적절했다.

GameplayAbility가 Tag 부여

그래서 구조를 다음처럼 수정했다.

  • GA_Attack 실행
  • Activation Owned Tags에 공격 태그 등록
  • Ability 실행 중 자동으로 태그 유지
  • Ability 종료 시 태그 자동 제거

덕분에 별도의 GE를 추가/제거하지 않아도
공격 중 상태를 자연스럽게 표현할 수 있게 되었다.

결과적으로:

지속적인 상태 표현은 GE,
Ability 실행 중 상태 표현은 Activation Owned Tags

로 역할을 명확하게 분리했다.


MoveToActor의 AlreadyArrived 처리 문제

AI가 공격을 마친 뒤, 플레이어가 바로 근처에 있어도 다시 공격하지 못하는 문제가 있었다.

원인은 공격 종료 후 항상 Move State로 전환되도록 구성한 흐름에 있었다.

기존 흐름은 다음과 같았다.

  • 공격 종료
  • StateTree가 Move State 전환
  • MoveToActor() 호출
  • 이동 완료 시 OnMoveCompleted()에서 다음 상태 처리

문제는 대상이 이미 Acceptance Radius 내부에 있는 경우였다.

이때 MoveToActor()는 실제 이동 없이 AlreadyArrived를 반환했고,
ReceiveMoveCompleted에 바인딩해둔 OnMoveCompleted()가 호출되지 않았다.

// MoveToTartgetActorTask.cpp

//  이미 도착한 경우
else if (MoveRequestResult == EMoveRequestResult::AlreadyArrived)
{
	CachedAIController->ReceiveMoveCompleted.RemoveDynamic(this, 
    										&UMoveToTargetActorTask::OnMoveCompleted);
	return;	// 공격 State로 전환
}

결과적으로 AI는 이동도 하지 않고, 다음 상태 전환도 발생하지 않아 공격을 다시 수행하지 못했다.

해결 방법은 단순했다.

else if (MoveRequestResult == EMoveRequestResult::AlreadyArrived)
{
	CachedAIController->ReceiveMoveCompleted.RemoveDynamic(this, 
    										&UMoveToTargetActorTask::OnMoveCompleted);
    OnMoveCompleted(...);
}

이미 도착한 상태라면 이동 완료 콜백을 직접 호출하도록 수정했다.

이후에는:

  • 공격 종료
  • Move State 진입
  • 이미 근처임을 감지
  • 즉시 이동 완료 처리
  • 다시 Attack State 전환

흐름이 자연스럽게 이어지도록 개선할 수 있었다.

profile
반갑습니다

0개의 댓글