이번 작업에서는 플레이어가 데미지를 받고, 체력이 감소하며, 체력이 0이 되었을 때 사망 처리까지 이어지는 기본 전투 흐름을 구현했다. 단순히 체력 변수 하나를 추가하는 작업처럼 보이지만, 멀티플레이 환경에서는 누가 체력을 깎을 권한을 가지는가, 클라이언트에는 어떻게 동기화되는가, 사망 연출과 UI는 어디에서 처리하는가를 함께 고려해야 했다.
구현 목표
- 플레이어 체력을 PlayerState에서 관리하기
- AI 공격으로 플레이어가 데미지를 받게 하기
- 체력 값을 네트워크로 동기화하기
- 체력이 0이 되면 캐릭터가 사망 처리되게 하기
- 죽음 몽타주와 종료 UI 흐름을 연결하기
PlayerState에 체력 추가
플레이어의 체력은 PlayerCharacter가 아니라 PlayerState에 추가했다. 이유는 체력이 단순한 애니메이션 상태가 아니라 플레이어에게 귀속되는 데이터이기 때문이다. 캐릭터는 죽거나 리스폰될 수 있지만, 플레이어의 점수, 체력, 데스 카운트 같은 데이터는 PlayerState에서 관리하는 쪽이 멀티플레이 구조에 더 잘 맞는다.
AMainPlayerCharacterState에는 현재 체력과 최대 체력을 추가했다.
UPROPERTY(EditAnywhere, BlueprintReadOnly, ReplicatedUsing=OnRep_Health, Category="Player|Health")
float CurrentHealth = 100.0f;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Replicated, Category="Player|Health")
float MaxHealth = 100.0f;
체력 감소 함수는 서버에서만 실행되도록 했다.
void AMainPlayerCharacterState::ApplyDamage(float DamageAmount)
{
if (!HasAuthority()) return;
CurrentHealth = FMath::Clamp(CurrentHealth - DamageAmount, 0.0f, MaxHealth);
}
이 구조에서 중요한 점은 클라이언트가 직접 체력을 깎지 않는다는 것이다. 클라이언트는 입력이나 피격 이벤트를 볼 수는 있지만, 실제 체력 변경은 서버에서만 일어난다. 그래야 멀티플레이에서 각 클라이언트의 체력 값이 서로 달라지는 문제를 막을 수 있다.
체력 Replicate 설정
체력을 복제하려면 GetLifetimeReplicatedProps에 등록해야 한다.
void AMainPlayerCharacterState::GetLifetimeReplicatedProps(
TArray<FLifetimeProperty>& OutLifetimeProps
) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(AMainPlayerCharacterState, CurrentHealth);
DOREPLIFETIME(AMainPlayerCharacterState, MaxHealth);
}
처음에는 이 부분에서 문제가 있었다. MaxHealth를 DOREPLIFETIME에 등록했지만, 헤더의 UPROPERTY에 Replicated를 붙이지 않아 에디터가 런타임에 멈췄다.
에러 메시지는 다음과 같았다.
Attempt to replicate property 'MaxHealth' that was not tagged to replicate!
Please use 'Replicated' or 'ReplicatedUsing'
원인은 명확했다. 언리얼에서는 어떤 변수를 복제 목록에 등록하려면, 해당 변수 자체도 UPROPERTY에서 복제 대상으로 표시되어 있어야 한다.
해결 방법은 두 가지다.
UPROPERTY(Replicated)
float MaxHealth;
또는 MaxHealth를 복제하지 않을 거라면 아래 줄을 제거하면 된다.
DOREPLIFETIME(AMainPlayerCharacterState, MaxHealth);
이번에는 최대 체력도 UI에서 사용할 가능성이 있으므로 Replicated를 붙이는 쪽으로 해결했다.
Event AnyDamage를 통한 피격 처리
AI가 플레이어를 공격했을 때 데미지를 받게 하기 위해 언리얼 기본 데미지 시스템을 사용했다. 핵심은 Event AnyDamage다.
Event AnyDamage는 해당 액터가 Apply Damage를 통해 데미지를 받았을 때 자동으로 호출된다. 즉, AI가 플레이어에게 데미지를 주려면 AI 쪽에서 언리얼 기본 Apply Damage를 호출하고, 플레이어 쪽에서는 Event AnyDamage로 받으면 된다.
흐름은 다음과 같다.
AI 공격
→ Apply Damage 호출
→ PlayerCharacter의 Event AnyDamage 실행
→ PlayerState 가져오기
→ ApplyDamage 호출
→ CurrentHealth 감소
처음에는 Get Player State 노드를 잘못 사용했다. Player State Index 0 방식으로 가져오면 현재 캐릭터의 PlayerState가 아니라 0번 플레이어의 PlayerState를 가져오는 식으로 꼬일 수 있다.
해결은 현재 캐릭터의 컨트롤러를 통해 PlayerState를 가져오는 방식이었다.
Event AnyDamage
→ Get Controller
→ Get Player State
→ Cast To BP_MainPlayerCharacterState
→ ApplyDamage
이렇게 하니 현재 피격된 플레이어 캐릭터의 PlayerState에 접근할 수 있었다.
트러블슈팅: Cast Object 핀 타입 에러
Blueprint에서 Cast To BP_MainPlayerCharacterState에 직접 PlayerState 변수를 연결했을 때 컴파일 에러가 발생했다.
The type of Object is undetermined
Unsupported type Wildcard on pin Object
원인은 직접 만든 PlayerState 변수의 값이 실제 런타임 PlayerState가 아니었기 때문이다. 변수의 기본값은 None이었고, Cast의 Object 핀이 어떤 타입을 받아야 하는지 판단하지 못했다.
해결은 실제 노드 흐름에서 PlayerState를 가져오는 것이었다.
Get Controller
→ Get Player State
→ Cast Object
즉, 임의 변수 대신 런타임에 실제 소유 중인 Controller에서 PlayerState를 가져와야 했다.
체력 출력 디버깅
체력이 실제로 줄어드는지 확인하기 위해 Print String을 연결했다.
ApplyDamage
→ Get CurrentHealth
→ Get MaxHealth
→ Format Text
→ Print String
출력 형식은 다음과 같이 잡았다.
HP: Current / Max
이를 통해 AI가 공격할 때마다 체력이 감소하는지 바로 확인할 수 있었다. 특히 멀티플레이에서는 서버에서만 체력이 변경되기 때문에, 화면에 출력되는 위치와 타이밍을 확인하는 것이 중요했다.
사망 처리 구조
체력이 0 이하가 되면 캐릭터가 죽도록 APlayerCharacter에 Die() 함수를 추가했다.
사망 상태는 캐릭터의 몸 상태에 해당한다. 따라서 체력 데이터는 PlayerState가 관리하지만, 이동 정지, 충돌 비활성화, 죽음 몽타주 재생 같은 실제 월드상의 처리는 Character에서 담당하는 것이 자연스럽다.
UPROPERTY(Replicated, BlueprintReadOnly, Category="Death")
bool bIsDead = false;
UFUNCTION(BlueprintCallable, Category="Death")
void Die();
UFUNCTION(NetMulticast, Reliable)
void MulticastDie();
Die()는 서버에서만 실행되도록 했다.
void APlayerCharacter::Die()
{
if (!HasAuthority()) return;
if (bIsDead) return;
bIsDead = true;
MulticastDie();
SetLifeSpan(5.0f);
}
MulticastDie()에서는 모든 클라이언트에서 죽음 연출이 보이도록 처리했다.
void APlayerCharacter::MulticastDie_Implementation()
{
if (UCharacterMovementComponent* MovementComp = GetCharacterMovement())
{
MovementComp->DisableMovement();
MovementComp->StopMovementImmediately();
}
if (AController* MyController = GetController())
{
DisableInput(Cast<APlayerController>(MyController));
}
GetCapsuleComponent()->SetCollisionEnabled(ECollisionEnabled::NoCollision);
if (DeathMontage && GetMesh() && GetMesh()->GetAnimInstance())
{
GetMesh()->GetAnimInstance()->Montage_Play(DeathMontage);
}
}
이렇게 하면 플레이어가 죽었을 때 이동이 멈추고, 입력이 막히고, 충돌이 꺼지며, 죽음 몽타주가 모든 클라이언트에서 재생된다.
트러블슈팅: protected 함수 접근 에러
PlayerState에서 Character의 Die()를 호출하려고 하자 다음 에러가 발생했다.
APlayerCharacter::Die cannot access protected member
원인은 Die() 함수가 protected 영역에 있었기 때문이다. PlayerState 같은 외부 클래스에서 호출하려면 public 영역에 있어야 한다.
해결은 간단했다.
public:
UFUNCTION(BlueprintCallable, Category="Death")
void Die();
함수의 역할상 외부 시스템에서 죽음 처리를 요청할 수 있으므로 public으로 두는 것이 적절했다.
죽음 후 Destroy 처리
처음에는 죽자마자 Destroy를 생각했지만, 바로 Destroy하면 죽음 몽타주가 제대로 보이지 않을 수 있다. 또한 멀티플레이에서는 다른 클라이언트가 사망 연출을 보기 전에 캐릭터가 사라질 수도 있다.
그래서 바로 Destroy하지 않고 SetLifeSpan을 사용했다.
SetLifeSpan(5.0f);
이렇게 하면 죽음 몽타주가 재생된 뒤 일정 시간이 지나 캐릭터가 자동 제거된다. 나중에 리스폰 시스템을 구현할 때는 Destroy 이후 GameMode에서 새 Pawn을 Spawn하고 Controller가 다시 Possess하는 흐름으로 확장할 수 있다.
종료 UI 처리 위치
사망 후 종료 UI는 Character가 아니라 PlayerController에서 처리하는 것이 더 적절하다. UI는 월드상의 캐릭터 몸이 아니라, 해당 플레이어의 로컬 화면에 속하기 때문이다.
따라서 AMyPlayerController에 이미 있던 Client_OpenResultUI()를 활용했다.
void AMyPlayerController::Client_OpenResultUI_Implementation()
{
if (!IsValid(ResultWidgetInstance)) return;
ResultWidgetInstance->AddToViewport();
FInputModeUIOnly InputMode;
SetInputMode(InputMode);
bShowMouseCursor = true;
}
사망 시에는 서버에서 죽음을 확정하고, 죽은 플레이어의 Controller에만 Client RPC를 호출하는 구조가 적절하다.
if (AMyPlayerController* PC = Cast<AMyPlayerController>(GetController()))
{
PC->Client_OpenResultUI();
}
이렇게 하면 죽음 몽타주는 모든 클라이언트에게 보이고, 종료 UI는 죽은 플레이어 본인 화면에만 표시된다.
로비 카메라 트러블슈팅
체력 시스템을 테스트하던 중 전투맵에서 갑자기 에디터가 멈추는 문제가 발생했다. 원인은 체력 코드가 아니라 MyPlayerController의 로비 카메라 코드였다.
기존 코드에서는 LobbyMainCamera 태그를 가진 카메라를 찾고 있었다.
UGameplayStatics::GetAllActorsWithTag(
GetWorld(),
FName("LobbyMainCamera"),
FoundActors
);
ensureMsgf(FoundActors.Num() > 0, TEXT("None Camera"));
SetViewTargetWithBlend(FoundActors[0], 0.f);
전투 테스트 레벨에는 해당 태그를 가진 카메라가 없었기 때문에 ensure가 발생했고, 배열이 비어 있음에도 FoundActors[0]에 접근할 위험이 있었다.
해결은 방어 코드를 추가하는 것이었다.
void AMyPlayerController::Find_ApplyLobbyCamera()
{
TArray<AActor*> FoundActors;
UGameplayStatics::GetAllActorsWithTag(
GetWorld(),
FName("LobbyMainCamera"),
FoundActors
);
if (FoundActors.Num() == 0)
{
UE_LOG(LogTemp, Warning, TEXT("None LobbyMainCamera"));
return;
}
SetViewTargetWithBlend(FoundActors[0], 0.f);
}
또한 로비 관련 UI와 카메라 코드는 로비맵에서만 실행되도록 분기했다.
const bool bIsLobbyMap = GetWorld()->GetMapName().Contains(TEXT("Lobby"));
if (bIsLobbyMap)
{
Find_ApplyLobbyCamera();
FInputModeUIOnly InputMode;
SetInputMode(InputMode);
bShowMouseCursor = true;
}
else
{
FInputModeGameOnly InputMode;
SetInputMode(InputMode);
bShowMouseCursor = false;
}
이 문제를 통해 PlayerController에 로비와 전투 로직이 섞여 있을 때, 맵 조건을 명확히 나누지 않으면 전투 테스트에서도 로비 코드가 실행될 수 있다는 점을 확인했다.
GameMode 분리 고민
로비와 전투를 하나의 GameMode / PlayerController로 처리할 수도 있지만, 기능이 많아질수록 분리하는 편이 더 안정적이다.
이상적인 구조는 다음과 같다.
LobbyGameMode
- LobbyPlayerController
- Lobby UI
- Lobby Camera
- UIOnly Input Mode
BattleGameMode
- Battle PlayerController
- PlayerCharacter
- PlayerState
- GameOnly Input Mode
- Combat HUD
다만 현재는 프로젝트 진행 속도를 위해 하나의 PlayerController 안에서 맵 이름으로 분기하는 방식을 사용했다. 나중에 로비와 전투 기능이 더 커지면 GameMode와 PlayerController를 분리하는 방향이 좋다.
최종 흐름 정리
이번 작업을 통해 플레이어 피격부터 사망까지의 흐름은 다음과 같이 정리되었다.
AI 공격
→ Apply Damage 호출
→ PlayerCharacter Event AnyDamage 실행
→ Controller에서 PlayerState 가져오기
→ MainPlayerCharacterState.ApplyDamage
→ CurrentHealth 감소
→ CurrentHealth <= 0 체크
→ PlayerCharacter.Die
→ MulticastDie
→ 죽음 몽타주 재생
→ 이동/입력/충돌 비활성화
→ 종료 UI 표시
→ 일정 시간 뒤 Destroy
마무리
이번 작업에서 가장 중요했던 부분은 단순히 체력을 깎는 것이 아니라, 멀티플레이 기준으로 책임을 나누는 것이었다.
PlayerState는 체력 데이터를 관리하고, PlayerCharacter는 죽음 연출과 실제 캐릭터 상태를 처리하며, PlayerController는 종료 UI처럼 로컬 플레이어 화면에 필요한 처리를 담당한다.
처음에는 Blueprint 연결이나 Replicate 설정에서 여러 번 막혔지만, 결과적으로 피격, 체력 감소, 사망, UI 표시까지 이어지는 기본 전투 흐름을 만들 수 있었다. 이후에는 이 구조 위에 무기별 데미지, 총알, 리스폰, 게임 종료 조건을 확장하면 된다.
'언리얼엔진 > 구현·실습' 카테고리의 다른 글
| 외부 캐릭터를 기존 TPS 시스템에 붙이며 겪은 리타겟팅 트러블슈팅 (0) | 2026.07.09 |
|---|---|
| Unreal Engine 무기 장착 시스템 확장성 개선하기 (0) | 2026.07.08 |
| Unreal Engine TPS 캐릭터 구현 정리 (0) | 2026.07.01 |
| Unreal Engine 캐릭터 시스템 구현기 (0) | 2026.07.01 |
| 언레얼엔진 개임게발 5일차 (0) | 2026.02.27 |