오늘은 이미 구현된 데이터 테이블 기반 무기 시스템 위에 캐릭터 전투 애니메이션을 연결하고, 캐릭터 상태와 실제 게임 로직이 불일치하는 문제를 개선했다.
주요 작업은 다음과 같다.
- Rifle/Pistol 무기 타입별 발사 몽타주 분리
- 조준 상태에서 발사 반동이 사라지는 AnimGraph 문제 해결
- 달리기 중 발사를 서버에서 차단
- 달리기 중 조준 시 걷기 상태로 전환
- 무기 교체 시점을 몽타주 노티파이와 동기화
- Aim Offset의 극단적인 Pitch로 인한 팔 관절 왜곡 방지
1. 무기 타입별 발사 몽타주 분리
기존 무기 시스템은 FWeaponData의 EWeaponType을 통해 현재 무기가 Rifle인지 Pistol인지 구분할 수 있었다.
따라서 무기 클래스에 애니메이션 정보를 중복으로 넣지 않고, 캐릭터가 무기 타입에 맞는 몽타주를 선택하도록 구성했다.
void APlayerCharacter::PlayAttackMontage(
EWeaponType WeaponType)
{
if (!GetMesh())
{
return;
}
UAnimInstance* AnimInstance =
GetMesh()->GetAnimInstance();
if (!AnimInstance)
{
return;
}
UAnimMontage* MontageToPlay = nullptr;
switch (WeaponType)
{
case EWeaponType::Rifle:
MontageToPlay = RifleAttackMontage;
break;
case EWeaponType::Pistol:
MontageToPlay = PistolAttackMontage;
break;
default:
break;
}
if (MontageToPlay)
{
AnimInstance->Montage_Play(MontageToPlay);
}
}
무기의 Multicast 발사 이펙트에서 해당 함수를 호출해 모든 클라이언트가 발사 모션을 볼 수 있도록 했다.
PlayerCharacter->PlayAttackMontage(
CurrentWeaponData.WeaponType
);
이 구조에서는 무기가 발사 사실과 무기 타입만 전달하고, 실제 캐릭터 애니메이션 선택은 캐릭터가 담당한다.
2. 조준 상태에서 발사 몽타주가 사라지는 문제
문제 상황
일반 상태에서는 발사 몽타주와 반동이 정상적으로 재생되었지만, 조준 상태로 전환하면 동일한 몽타주가 전혀 보이지 않았다.
처음에는 Rifle 몽타주나 무기 타입 판별 문제로 의심했지만, 실제 원인은 AnimGraph의 분기 구조였다.
기존 구조에서는 IsAiming으로 두 포즈를 나눈 뒤, UpperBody Slot이 비조준 분기에만 배치되어 있었다.
비조준 포즈 → UpperBody Slot
조준 포즈 → UpperBody Slot 우회
↓
Blend Poses by Bool
따라서 조준 상태에서는 몽타주가 정상적으로 재생되더라도 최종 출력 포즈에 반영되지 않았다.
해결 방향
조준 여부에 따른 포즈 결정을 먼저 끝낸 뒤, 최종 포즈에 UpperBody Slot을 공통 적용하도록 구조를 변경했다.
Aim False Pose ─┐
├→ Blend Poses by Bool
Aim True Pose ──┘
↓
Layered Blend Per Bone
↑
Slot "UpperBody"
Layered Blend Per Bone은 하체 이동 애니메이션을 유지하면서 척추 위쪽에만 발사 몽타주가 적용되도록 설정했다.
권장 설정은 다음과 같다.
Branch Bone: spine_01 또는 spine_02
Blend Depth: 3~5
Mesh Space Rotation Blend: 활성화
이번 문제를 통해 몽타주가 “재생되지 않는 것”과 “재생되지만 최종 포즈에서 제거되는 것”은 구분해서 디버깅해야 한다는 점을 확인했다.
UpperBody가 False부분만 우회하는 상황
true부분도 추가한 모습
3. 달리기 애니메이션과 실제 발사 상태의 불일치
문제 상황
달리기 애니메이션에서는 캐릭터가 총을 아래로 내리고 있었지만, 실제 무기 로직에서는 여전히 발사가 가능했다.
이 문제는 애니메이션 상태를 곧바로 게임플레이 규칙으로 취급했기 때문에 발생했다.
총을 내리는 포즈는 시각적 표현일 뿐이며, 서버의 Fire() 함수에는 아무런 영향을 주지 않는다.
따라서 발사 가능 여부는 무기 로직에서 명시적으로 검사해야 한다.
bool AWeaponBase::CanFire() const
{
if (OwnerCharacter &&
OwnerCharacter->IsSprinting())
{
return false;
}
if (CurrentMagazineAmmo <= 0)
{
return false;
}
if (bIsReloading)
{
return false;
}
// 발사 간격 검사 생략
return true;
}
캐릭터 내부 상태에 직접 접근하지 않고 Getter를 통해 확인하도록 했다.
bool APlayerCharacter::IsSprinting() const
{
return bIsSprinting;
}
연사 타이머 문제
Fire() 내부에서만 CanFire()를 검사하면 달리는 동안 탄환은 발사되지 않더라도 연사 타이머가 계속 생성될 수 있다.
이 경우 달리기가 끝나는 순간, 버튼을 다시 누르지 않았는데도 타이머에 의해 발사가 재개될 가능성이 있다.
이를 막기 위해 StartFire() 진입 시점에도 검사를 추가했다.
void AWeaponBase::StartFire()
{
if (!CanFire())
{
return;
}
switch (CurrentWeaponData.FireMode)
{
case EFireMode::Single:
Fire();
break;
case EFireMode::Auto:
Fire();
GetWorld()->GetTimerManager().SetTimer(
FireTimerHandle,
this,
&AWeaponBase::Fire,
CurrentWeaponData.FireRate,
true
);
break;
case EFireMode::Burst:
BurstCount = 0;
BurstFire();
break;
}
}
달리기 시작 시 기존 연사와 점사 타이머도 종료했다.
void AWeaponBase::StopFire()
{
if (!GetWorld())
{
return;
}
GetWorld()->GetTimerManager().ClearTimer(
FireTimerHandle
);
GetWorld()->GetTimerManager().ClearTimer(
BurstTimerHandle
);
BurstCount = 0;
}
즉, 발사 제한은 단순히 탄환 생성을 막는 데서 끝나지 않고, 발사 상태 머신과 타이머까지 함께 정리해야 했다.
4. 달리기 중 조준 입력 처리
초기 설계에서는 달리기와 조준을 동시에 유지하는 방향을 고려했지만, 최종적으로 다음 규칙을 적용했다.
달리기 중 조준 입력
→ 달리기 상태 종료
→ 이동 속도를 걷기로 변경
→ 조준 상태 진입
→ 발사 가능
StartAim()에서 로컬 캐릭터와 서버의 달리기 상태를 함께 종료했다.
void APlayerCharacter::StartAim()
{
bIsAiming = true;
bIsSprinting = false;
if (UCharacterMovementComponent* MovementComp =
GetCharacterMovement())
{
MovementComp->MaxWalkSpeed = WalkSpeed;
}
if (IsLocallyControlled())
{
ServerStopSprint();
}
SetActorTickEnabled(true);
if (AimCameraCurve)
{
AimCameraTimeline.Play();
}
else
{
UpdateAimCamera(1.0f);
SetActorTickEnabled(false);
}
}
로컬에서 먼저 속도를 변경한 이유는 서버 응답을 기다리지 않고 즉각적인 조작감을 제공하기 위해서다.
서버에서는 최종적으로 bIsSprinting과 MaxWalkSpeed를 확정한다.
void APlayerCharacter::ServerStopSprint_Implementation()
{
bIsSprinting = false;
if (UCharacterMovementComponent* MovementComp =
GetCharacterMovement())
{
MovementComp->MaxWalkSpeed = WalkSpeed;
}
}
반대로 조준 중 달리기 입력은 무시하도록 처리했다.
void APlayerCharacter::StartSprint(
const FInputActionValue& Value)
{
if (bIsAiming)
{
return;
}
StopFire();
bIsSprinting = true;
if (UCharacterMovementComponent* MovementComp =
GetCharacterMovement())
{
MovementComp->MaxWalkSpeed = 600.0f;
}
if (IsLocallyControlled())
{
ServerStartSprint();
}
}
이렇게 애니메이션, 이동 속도, 발사 가능 여부가 하나의 상태 규칙을 공유하도록 맞췄다.
에임을 하면 걷는속도로 돌아가는 모습
5. 무기 교체 타이밍을 AnimNotify로 동기화
문제 상황
기존 무기 교체 코드는 다음 순서로 실행됐다.
기존 무기 제거 및 새 무기 생성
→ 캐릭터 소켓에 Attach
→ 장착 몽타주 재생
이 구조에서는 캐릭터가 기존 총을 내리기도 전에 새 무기로 변경되기 때문에 애니메이션과 실제 무기 Actor의 교체 타이밍이 어긋났다.
단순히 몽타주 재생 시간을 코드에 하드코딩할 수도 있지만, 애니메이션 길이나 재생 속도가 변경되면 다시 수정해야 한다.
따라서 몽타주에 ChangeWeapon 노티파이를 배치하고, 해당 시점을 실제 무기 교체의 커밋 지점으로 사용했다.
ChangeWeapon 노티파이
요청과 적용 단계 분리
무기 변경 요청이 들어왔을 때 바로 생성하지 않고, 변경할 RowName만 임시 저장한다.
UPROPERTY()
FName PendingWeaponRowName = NAME_None;
void APlayerCharacter::EquipWeaponByRowNameInternal(
FName WeaponRowName)
{
if (!HasAuthority() || WeaponRowName.IsNone())
{
return;
}
if (CurrentWeaponRowName == WeaponRowName &&
IsValid(CurrentWeapon))
{
return;
}
EWeaponType NewWeaponType;
FDataTableRowHandle WeaponData;
if (!FindWeaponByRowName(
WeaponRowName,
NewWeaponType,
WeaponData))
{
return;
}
if (bIsAiming)
{
CancelAimInstant();
}
PendingWeaponRowName = WeaponRowName;
MulticastPlayEquipMontage(NewWeaponType);
}
이 시점에서는 몽타주만 재생되고 실제 무기는 변경되지 않는다.
ChangeWeapon 노티파이가 호출되면 Pending 데이터를 커밋한다.
void APlayerCharacter::CommitPendingWeaponChange()
{
if (!HasAuthority() ||
PendingWeaponRowName.IsNone())
{
return;
}
const FName WeaponRowName =
PendingWeaponRowName;
EWeaponType NewWeaponType;
FDataTableRowHandle WeaponData;
if (!FindWeaponByRowName(
WeaponRowName,
NewWeaponType,
WeaponData))
{
PendingWeaponRowName = NAME_None;
return;
}
if (!EquipWeaponActorFromRow(WeaponData))
{
PendingWeaponRowName = NAME_None;
return;
}
CurrentWeaponType = NewWeaponType;
CurrentWeaponRowName = WeaponRowName;
PendingWeaponRowName = NAME_None;
if (IsLocallyControlled())
{
BP_OnWeaponChanged(WeaponData);
}
}
AnimBP에서는 다음 흐름으로 캐릭터 함수를 호출한다.
AnimNotify_ChangeWeapon
→ Try Get Pawn Owner
→ Cast To BP_PlayerCharacter
→ HandleChangeWeaponNotify
서버와 클라이언트에서 노티파이가 중복 실행될 가능성이 있기 때문에, 실제 변경은 서버에서만 수행하고 Pending 값을 커밋 직후 제거했다.
void APlayerCharacter::HandleChangeWeaponNotify()
{
if (HasAuthority())
{
CommitPendingWeaponChange();
return;
}
if (IsLocallyControlled())
{
ServerHandleChangeWeaponNotify();
}
}
이 구조는 데이터 변경을 즉시 적용하지 않고, 실제로 적용 가능한 시점까지 보류했다가 커밋한다는 점에서 간단한 트랜잭션과 유사하다.
트러블슈팅 정리
오늘 해결한 문제들은 모두 애니메이션 자체보다 시스템 간 상태 동기화에서 발생했다.
| 조준 중 발사 몽타주 미출력 | 조준 분기가 UpperBody Slot을 우회 | 조준 포즈 결합 후 Slot 공통 적용 |
| 달리기 중 발사 가능 | 총을 내리는 애니메이션만 존재 | 서버 CanFire()에서 Sprint 검사 |
| 달리기 종료 후 연사 재개 | 발사 타이머가 계속 실행 | StartFire() 선행 검사 및 타이머 제거 |
| 조준과 달리기 상태 충돌 | 시각 상태와 이동 속도가 별도 관리 | 조준 시 로컬·서버 달리기 동시 해제 |
| 무기 교체 타이밍 불일치 | Actor 교체 후 몽타주 재생 | Pending Row + AnimNotify Commit |
마무리
이번 작업에서 가장 중요했던 부분은 애니메이션을 단순한 시각 효과가 아니라 게임플레이 상태 전환의 표현으로 정리한 것이다.
다만 최종 권한은 여전히 서버가 가진다.
- 발사 가능 여부는 서버가 판정한다.
- 달리기 상태는 서버가 확정한다.
- 무기 Actor의 생성과 교체는 서버가 수행한다.
- 몽타주 노티파이는 실제 변경 시점을 제공한다.
- 클라이언트는 카메라와 애니메이션을 즉시 반영해 반응성을 확보한다.
결과적으로 데이터 테이블 기반 무기 시스템을 유지하면서도 발사, 이동, 조준, 몽타주, 실제 무기 Actor가 서로 같은 상태 전환 시점을 공유하도록 개선할 수 있었다.
'언리얼엔진 > 구현·실습' 카테고리의 다른 글
| [UE5] 장비 슬롯과 숫자 단축키를 데이터 기반 무기 교체 시스템으로 연결하기 (0) | 2026.07.16 |
|---|---|
| [UE5] 애니메이션 문제처럼 보였던 이동 버그를 무기 충돌에서 찾아내기까지 (0) | 2026.07.15 |
| Hitscan 판정은 즉시, 총알은 보이게: UE5 Niagara 기반 네트워크 트레이서 구현기 (0) | 2026.07.14 |
| UE5 멀티플레이 환경에서 인벤토리–무기 장착 파이프라인 연결하기 (0) | 2026.07.11 |
| 외부 캐릭터를 기존 TPS 시스템에 붙이며 겪은 리타겟팅 트러블슈팅 (0) | 2026.07.09 |





