1. 문제 상황 은신 플레이를 위한 '앉기(Crouch)' 기능을 추가했으나, 앉아서 이동할 때도 서서 걸을 때와 동일한 크기의 발소리가 나고 적의 어그로 범위(소리 전파 반경)가 그대로 유지되어 은신 시스템의 의미가 퇴색되는 문제가 발생했다. 또한, 특정 층의 홍수 기믹에 맞춰 캐릭터가 물을 밟을 경우 발소리 에셋이 일반 구두 소리에서 '찰박거리는 물소리'로 즉각 변환되고, 물속에서는 앉더라도 소음이 줄어들지 않도록 환경적 디테일을 구현해야 했다.
2. 해결 과정 1: 이동 상태에 따른 속도 제어 로직 단일화 기존 UpdateStatus 함수(매 틱 상태를 업데이트하는 함수) 내에서는 걷기/뛰기/스턴/수중 상태 등 이동 속도(MaxWalkSpeed)를 결정하는 제어문이 여러 곳에 중복으로 흩어져 있어 로직이 꼬일 위험이 컸다. 이를 해결하기 위해 이동 기반 속도(BaseSpeed)를 먼저 결정한 후, 환경 요인(수중 50% 감속 등)을 Multiplier로 곱하여 한 번에 계산하는 방식으로 로직을 단일화 및 최적화했다.
3. 해결 과정 2 & 3: 동적 사운드 에셋 스왑 및 어그로 배율(Multiplier) 연동 사운드 출력과 AI 어그로 전파를 모두 담당하는 GenerateFootstep() 함수에 동적 배율 시스템을 도입했다.
- 환경 및 자세 판별: 수중 상태인지 확인한 뒤, 물속이 아닌 맨땅에서 앉았을 때만 소리 배율(SoundMultiplier)을 0.5로 설정하고, 그 외(서 있거나 물속)의 경우에는 1.0으로 강제하여 '물속에서는 은신 걷기를 해도 찰박이는 소리 때문에 들킨다'는 기획적 디테일을 살렸다.
- 에셋 스와핑: 상태에 따라 WaterFootstepSound와 FootstepSound를 동적으로 선택하여 출력하게 만들었다.
- 컴포넌트 연동: SoundPropagationComponent의 PropagateSound 함수가 이 배율 파라미터를 받아들일 수 있도록 인자를 추가하고, 내부 탐색 반경(Radius)에 곱하여 실제 어그로 핑이 찍히는 범위를 물리적으로 축소했다.
4. 트러블슈팅: 틱(Tick) 지뢰 제거 및 정확한 Z 축 수중 판정
- 어그로 틱(Tick) 폭탄 제거: 기존에는 수중 이동 감지 코드가 UpdateStatus 내에 하드코딩되어 있어, 물속에서 움직일 때마다 1초에 60번씩 적 AI에게 소음 자극(Stimulus)을 발생시키는 치명적인 성능 저하 및 AI 고장 요인이 존재했다. 이를 완전히 삭제하고, 오직 애니메이션 노티파이(Anim Notify)에 의해 발이 땅에 닿을 때만 1회 호출되는 GenerateFootstep 내부로 어그로 전파 로직을 통합하여 안전하게 최적화했다.
- 수중 판정(Z축) 보정: GetActorLocation().Z는 캐릭터 캡슐 컴포넌트의 정중앙(배꼽) 높이를 반환하므로, 물이 허리까지 차올라야만 수중 발소리가 나는 문제가 있었다. 중앙 좌표에서 CapsuleHalfHeight를 빼서 정확한 발바닥의 좌표를 구한 뒤, 수위(Water Level)와 비교하도록 IsInWater() 함수를 재작성하여 물이 바닥에 닿자마자 즉각적으로 사운드 피드백이 전환되도록 개선했다.
1. Z축 보정을 통한 수중 판정 (OblivioCharacter.cpp)
bool AOblivioCharacter::IsInWater() const
{
if (UCapsuleComponent* Capsule = GetCapsuleComponent())
{
// 캡슐 중앙에서 절반 높이를 빼서 '발바닥' Z 좌표 도출
float FeetZ = GetActorLocation().Z - Capsule->GetScaledCapsuleHalfHeight();
// 발바닥보다 수위가 살짝이라도 높으면 수중으로 판정
return (FeetZ + 5.0f) < CurrentWaterLevel;
}
return false;
}
2. 속도 로직 단일화 (OblivioCharacter.cpp - UpdateStatus 내부)
bool bIsInWater = IsInWater();
float WaterSpeedMultiplier = bIsInWater ? 0.5f : 1.0f; // 수중 50% 감속
if (bIsStunned)
{
GetCharacterMovement()->MaxWalkSpeed = 0.0f;
}
else
{
// 상태에 따른 베이스 속도 설정
float BaseSpeed = WalkSpeed;
if (bIsCrouching) BaseSpeed = CrouchSpeed;
else if (bIsRunning) BaseSpeed = RunSpeed;
// 모든 감속 계수를 통합 적용
float FinalSpeed = BaseSpeed * WaterSpeedMultiplier;
if (bIsSlowed) FinalSpeed *= CurrentSlowMultiplier;
GetCharacterMovement()->MaxWalkSpeed = FinalSpeed;
}
3. 사운드 및 어그로 동적 조절 (OblivioCharacter.cpp - GenerateFootstep)
void AOblivioCharacter::GenerateFootstep()
{
// 타이머 무한 락 방지용 빈 람다 쿨다운 적용
if (GetWorld()->GetTimerManager().IsTimerActive(FootstepTimerHandle)) return;
GetWorld()->GetTimerManager().SetTimer(FootstepTimerHandle, FTimerDelegate::CreateLambda([](){}), 0.2f, false);
bool bInWater = IsInWater();
// 물 밖에서 앉았을 때만 소리와 어그로 반경 절반
float SoundMultiplier = (bIsCrouching && !bInWater) ? 0.5f : 1.0f;
// 상황에 맞는 사운드 에셋 스왑
USoundBase* SoundToPlay = bInWater ? WaterFootstepSound : FootstepSound;
if (IsValid(SoundToPlay)) {
UGameplayStatics::PlaySound2D(GetWorld(), SoundToPlay, SoundMultiplier);
}
// 애니메이션에 맞춰 1회만 계산된 배율로 어그로 전파
if (IsValid(SoundPropagationComp)) {
SoundPropagationComp->PropagateSound(SoundMultiplier);
}
}
4. 사운드 배율을 적용한 AI 자극 전파 (SoundPropagationComponent.cpp)
void USoundPropagationComponent::PropagateSound(float VolumeMultiplier)
{
FVector SoundLocation = GetOwner()->GetActorLocation();
// 볼륨 배율에 비례하여 실제 탐색 반경(어그로 범위) 축소
float FinalRadius = Radius * VolumeMultiplier;
TArray<AActor*> OverlapActors;
TArray<TEnumAsByte<EObjectTypeQuery>> EnemyObjectTypes;
EnemyObjectTypes.Add(UEngineTypes::ConvertToObjectType(ECC_Pawn));
EnemyObjectTypes.Add(UEngineTypes::ConvertToObjectType(ECC_WorldDynamic));
UKismetSystemLibrary::SphereOverlapActors(
GetWorld(),
SoundLocation,
FinalRadius,
EnemyObjectTypes,
AEnemyBase::StaticClass(),
TArray{ GetOwner() },
OverlapActors);
for (AActor* Result : OverlapActors)
{
if (AEnemyBase* Enemy = Cast<AEnemyBase>(Result))
{
Enemy->ReportStimulus(SoundLocation, StimulusType);
}
}
}