Today I Learned |
Unreal Engine 5|StateTree|AI|C++|7th-Team2
적의 공전 목적지(ChaseLocation)가 매 프레임 흔들려 이동이 불안정한 문제를 분석하던 중,
RetrieveEnemyTargetEvaluator.cpp를 직접 읽으면서 실행 구간이 두 덩어리로 나뉜다는 것을
확인했습니다.
void FRetrieveEnemyTargetEvaluator::Tick(...) const
{
// ── 구간 A: 매 프레임 ──────────────────────────────────
// TargetLocation, DistanceToTarget, ChaseLocation 갱신
// GetSlotLocation 호출 ← 이 안에 FRandRange가 있었음
InstanceData.AccumulatedTime += DeltaTime;
if (InstanceData.AccumulatedTime < TickInterval)
{
return; // ← 0.2초가 안 됐으면 여기서 반환
}
InstanceData.AccumulatedTime = 0.f;
// ── 구간 B: 0.2초마다 ──────────────────────────────────
// Perception 조회, 토큰 요청, bAttackable 계산
}
초기에는 "Tick 전체가 0.2초마다 실행된다"고 생각했는데,
AccumulatedTime 게이트 앞에 위치한 코드들이 매 프레임 실행된다는 것을
코드를 직접 읽어 확인했습니다.
EncirclementSubsystem::GetSlotLocation은 bUseOuterRadius일 때 호출마다 새 난수를 뽑고 있었습니다.
// Before — 매 호출(= 매 프레임)마다 새 난수
if (bUseOuterRadius)
{
TargetRadius += FMath::FRandRange(MinNoise, MaxNoise);
}
기본값 MinNoise=-100, MaxNoise=+10 기준으로 목적지가 매 프레임 200~310 사이를 진동했습니다.
VInterpTo(speed=7.0)가 있어도 입력이 매 프레임 ±110 범위로 점프하므로 수렴이 불가능한 상태였습니다.
처음에는 VInterpTo가 진동을 충분히 완화할 것이라고 생각했지만, 직접 수치를 계산해 보니
(speed=7.0, dt=0.016일 때 프레임당 이동 비율 ≈ 10.6%) 입력 자체가 매 프레임 점프하는 이상
보간이 의미가 없다는 것을 알 수 있었습니다.
핵심: StateTree Evaluator에서
AccumulatedTime게이트의 위치가 어디냐에 따라
매 프레임 실행되는 코드와 0.2초마다 실행되는 코드가 완전히 달라집니다.
"슬롯마다 목적지를 조금씩 다르게 해서 단조로운 공전을 방지한다"는 의도였는데,
노이즈가 슬롯 배정 시 확정되지 않고 조회할 때마다 새로 뽑히고 있었습니다.
조회 함수(GetSlotLocation)에 난수 생성 로직이 들어간 것이 원인이었습니다.
FRing 구조체에 슬롯별 노이즈를 저장하는 배열을 추가하고,
슬롯 배정(RequestSlot) 시 1회만 난수를 생성해 고정했습니다.
// EncirclementSubsystem.h — FRing 구조체에 SlotNoises 추가
struct FRing
{
TArray<TWeakObjectPtr<AActor>> Slots;
TArray<TWeakObjectPtr<AActor>> AttackTokens;
TArray<float> SlotNoises; // 슬롯별 노이즈 오프셋 (배정 시 1회 확정)
};
// GetSlotLocation — FRandRange 제거, 저장된 값 읽기
if (bUseOuterRadius)
{
if (const FRing* Ring = Rings.Find(Target))
{
if (Ring->SlotNoises.IsValidIndex(SlotIndex))
TargetRadius += Ring->SlotNoises[SlotIndex];
}
}
// Evaluator.cpp — RequestSlot 직후 1회만 설정
SlotIndex = EncSubsystem->RequestSlot(InstanceData.TargetPlayer, Pawn);
if (SlotIndex != INDEX_NONE)
{
const float Noise = FMath::FRandRange(StrafeMinNoise, StrafeMaxNoise);
EncSubsystem->FixSlotNoise(InstanceData.TargetPlayer, SlotIndex, Noise);
}
| 구분 | Before | After |
|---|---|---|
| 노이즈 생성 위치 | GetSlotLocation (조회마다) | RequestSlot 직후 1회 |
| 노이즈 저장 위치 | 없음 (매번 새로 계산) | FRing::SlotNoises |
GetSlotLocation 성격 | 부작용 있는 조회 | 순수 읽기 전용 |
| ChaseLocation 안정성 | 매 프레임 ±110 진동 | 슬롯 배정 후 고정 |
bHasToken이 반경(InnerRadius=100 / OuterRadius=300)을 결정하는 구조에서,
토큰 반납 후 0.2초 이내 재취득 사이클이 반복되면서 목적지가 200단위로 급변하는 문제도 확인했습니다.
초기 수정 시도(hold timer)에서는 bHasToken=true를 일정 시간 유지해 급변을 방지하려 했으나,
이로 인해 공격 불가 상태에서 InnerRadius에 갇히는 부작용이 발생했습니다.
근본 해결책은 반경 결정 기준을 bHasToken에서 bAttackable로 분리하는 것이었습니다.
// Before — 토큰 보유 여부로 반경 결정
const bool bUseOuterRadius = !InstanceData.bHasToken;
// After — 공격 가능 여부로 반경 결정
const bool bUseOuterRadius = !InstanceData.bAttackable;
이 변경으로 토큰은 오직 공격 허가(bAttackable) 판단에만 사용되고,
반경은 "공격 가능한 상태인가"라는 더 넓은 조건으로 결정됩니다.
공격 후 쿨다운 중에는 bAttackable=false → OuterRadius로 자연스럽게 이동하는 흐름이 됩니다.
- StateTree Evaluator는
AccumulatedTime게이트를 기준으로 매 프레임 섹션과 주기 섹션이 나뉩니다.- 공유 서브시스템의 조회 함수에 랜덤 로직이 있으면 호출 빈도만큼 난수가 발생합니다. 조회 함수는 순수 읽기 전용으로 유지해야 합니다.
- 상태(State)는 한 곳에서 1회 확정하고, 소유권이 명확한 구조체(
FRing)가 보유하도록 설계하면 여러 호출처가 일관된 값을 읽을 수 있습니다.- 첫 번째 수정(hold timer)이 새 버그를 만들었습니다. "무엇을 제어하는가"(반경 vs 공격 허가)를 분리하는 것이 올바른 설계였습니다.
Sparta Study 7th Team2 Final Project — 2026-06-08