*팀 프로젝트*
<작업한 내용들(07.17)>
비록 최종적으로 dev-multi 브랜치로 롤백하며 작업을 마무리했으나, 구현 과정에서 언리얼 엔진 5의 C++ 핵심 설계 패턴, UMG UI 최적화, 그리고 디버깅 프로세스에 대해 깊이 있게 이해할 수 있었다.
1. UMG 레이아웃 및 UI 최적화
옵션 위젯 슬라이더 길이 및 가독성 수정
- 문제 현상: C++에 USlider가 정상 바인딩되었음에도 화면에서 슬라이더가 비정상적으로 짧게 축소되는 현상 발생.
- 원인 및 해결: C++의 문제가 아닌 UMG 디자이너(WBP) 레이아웃 설정의 문제임. Horizontal Box Slot 내부의 Size 속성이 Auto로 되어 있어 발생한 문제로, 이를 Fill(비율 1.0)로 변경하여 남은 공간을 가로로 꽉 채우도록 수정함.
- 양방향 바인딩 시 무한 루프 방지: 슬라이더와 입력 창의 값을 실시간으로 동기화할 때, 텍스트 박스의 OnTextChanged를 사용하면 값이 바뀔 때마다 서로를 다시 호출하는 무한 루프 크래시가 발생할 수 있음. 플레이어가 엔터를 치거나 포커스를 잃었을 때만 동기화가 반영되도록 OnTextCommitted를 사용하는 것이 안전한 설계 원칙임을 배움.
ESC 메뉴 연속 입력 시 버벅임(Hitch) 해결
- 문제 현상: 메뉴가 켜지는 페이드 인 애니메이션 도중 유저가 ESC를 연속으로 누르면 Slate 렌더링 영역에서 버벅임이 발생함.
- 해결 방법: 새로운 애니메이션을 재생하기 전, 기존 애니메이션의 타임라인 연산을 확실히 정지시켜야 함. 이벤트 그래프의 오픈/클로즈 시작 초입에 Stop Animation 노드를 명시적으로 배치하여 기존 연산을 클리어한 후 Play Animation이 돌도록 구조를 개선함.
대량의 UI 에셋 임포트 및 최적화 (Slate Atlas)
- 아이콘 에셋 프로세스: Figma에서 내보낸 SVG 아이콘들을 PNG로 변환하여 언리얼 엔진에 임포트함.
- 일괄 프로퍼티 수정: Property Matrix를 사용하여 임포트한 14개의 UI 텍스처를 다중 선택한 뒤, UI 최적화에 필수적인 두 가지 옵션을 일괄 변경함.
- Compression Settings ➔ UserInterface2D (RGBA)로 변경하여 텍스처 깨짐 방지.
- Texture Group ➔ UI로 변경하여 런타임 스트리밍 버그 방지 및 Slate 시스템의 글로벌 아틀라스 시트 자동 취합 활성화 (드로우 콜 최소화).
- Gameplay Tag 기반 매핑 라이브러리 구축: 블루프린트 내부에서 지저분한 UV 좌표 계산을 걷어내고, Map 변수(StatusIconMap)를 생성하여 Gameplay Tag와 Texture 2D 오프젝트 레퍼런스를 1:1로 매칭하는 딕셔너리 구조를 구현함.
2. C++ 아키텍처 및 시스템 설계
스태미나 로직 및 상태 태그 연동 변경
- 기존 구조의 한계: 강제 타이머 만료 시 스태미나의 충전 상태와 상관없이 Exhausted 태그를 바로 제거하여, 스태미나가 소량만 차도 탈진 상태 UI 연출(빨간색 깜빡이)이 끊기는 문제가 있었음.
- 개선된 아키텍처 규칙: 스태미나가 0이 되면 3초간 완전 탈진 상태(회복 정지)를 유지하고, 이후 회복은 시작하되 최대 스태미나(MaxStamina)에 도달하기 전까지는 Exhausted 태그를 계속 유지하도록 설계함.
- 공중 상태 회복 방어: 점프 중 스태미나가 차오르지 않도록, AParcelCharacter에 구현된 공중 태그(Character.State.InAir) 존재 여부를 TickComponent에서 검사하여 회복 조건문에 방어벽(!bIsInAir)을 세움.
UE5 포맷 스트링 보안 검증기(Format String Sanitizer) 컴파일 에러 해결
- 에러 로그: FormatStringSan.h(108): Error C2338 : static_assert failed: 'not enough arguments provided to format string.'
- 원인: FText::Format() 문맥이나 로그 출력문에서 순수 텍스트로 100%라는 문구를 사용했을 때 발생함. 컴파일러가 % 기호를 printf 스타일의 형식 지정자 시작으로 오인하여 두 번째 인자값을 찾으려다가 발생한 빌드 에러임.
- 해결 방법: C++ 및 언리얼 엔진의 문자열 포맷팅 규칙에 따라 퍼센트 기호를 화면에 그대로 출력하고 싶을 때는 %% 형태로 이스케이프(Escape) 처리를 해야 함을 배움.
멀티플레이어 배송 로그(킬로그) 시스템 이벤트 전파 구조
- 서버에서 발생한 물리적 판정 신호를 모든 클라이언트 UI까지 안전하게 관통시키는 네트워크 레이어를 설계함.
- Server Only: UDeliveryRuleComponent::OnDeliveryCompleted 함수에서 배달 결과 및 BoxName 식별 매개변수 확보.
- Server ➔ Client: 규칙 컴포넌트가 AParcelGameState를 참조하여 Multicast RPC 함수(Multicast_NotifyDeliveryLog) 호출.
- Client UI: 각 클라이언트의 UParcelHUDWidget이 GameState의 동적 멀티캐스트 델리게이트를 수신하여 화면 우상단 컨테이너에 개별 로그 위젯을 순서대로 적재.
3. 머티리얼 기반 화면 이펙트 (비네트 효과)
- 기획 배경: 피격 시 또는 함정에 당했을 때 화면 전체가 단색으로 번쩍이면 유저에게 멀미와 눈의 피로를 유발하므로, 화면 가장자리만 부드럽게 물드는 비네트(Vignette) 필터를 제작하기로 함.
- 머티리얼 중심 설계: 해상도 변화에 영향받는 텍스처 대신, 수학 연산 기반의 UI 전용 다이내믹 머티리얼(M_ScreenVignette)을 제작함.
M_ScreenVignette 노드 구성 및 세팅
Material Domain: User Interface / Blend Mode: Translucent
가장자리 그라데이션 수학 연산: TextureCoordinate(UV) ➔ Subtract(0.5)로 중앙 정렬 ➔ Length로 중심 거리 계산 ➔ Power(3.0~4.0) 연산으로 외곽 밀착도 조절 ➔ 최종 Opacity 연결.
실시간 색상 파이프라인: Vector Parameter 노드를 생성하고 이름을 FlashColor로 지정하여 Final Color에 연결.
WBP 인프라 연동: Event Construct 단계에서 Create Dynamic Material Instance를 통해 머티리얼을 생성하고 VignetteMaterial 변수에 캐싱한 뒤 보더 위젯에 적용함. 데미지 피격(파트 A) 시에는 새빨간색으로, 조작 반전 트랩 지속(파트 B) 시에는 보라색으로 파라미터 값만 런타임에 실시간 변경하는 구조를 확립함.
4. 상태 기반 UI 리팩토링 및 블루프린트 디버깅 Lessons
Event-Driven과 Tick의 결합 필요성 인지
- 문제 상황: Event K2_OnCharacterStateChanged 스위치 구조는 Gameplay Tag가 변경(추가/삭제)될 때만 최초 1회 발동함. 이로 인해 Shift를 떼는 시점에는 태그가 변해 아이콘이 갱신되지만, WASD 이동을 멈추는 시점에는 가속도/속도(Velocity)만 0으로 변할 뿐 태그 변화가 없어 UI가 직전의 이동/스프린트 아이콘 상태로 멈춰 있는 현상이 발생함.
- 해결 방향: 태그 판정 및 속도 체크 로직을 하나의 독립된 함수(RefreshHUDIcon)로 포장하고, 태그가 변경될 때와 Event Tick이 돌 때 양쪽에서 동일한 함수를 호출하여 물리적 속도가 0이 되는 순간도 실시간 트래킹할 수 있도록 구조적 설계를 지향함.
Accessed None 경고 방어 코드 구축
- 원인: UI 틱이나 함수가 호출되는 순간, 유동적인 네트워크 환경이나 초기화 타이밍 문제로 인해 플레이어 캐릭터 포인터가 일시적으로 Null 상태(존재하지 않음)가 되어 런타임 에러가 발생함.
- 해결 방법: Get Owning Player Pawn에서 반환된 값을 사용할 때, 연산 노드 바로 앞에 회색 질문지 형태의 Is Valid 매크로 노드를 배치하여 포인터 유효성을 먼저 검증함. 오직 캐릭터가 확실히 살아있을 때만 Get Velocity 및 내부 로직이 동작하도록 안전장치를 마련함.
💡 최종 Takeaway
아키텍처 설계와 노드 배선 과정에서 발생한 예외 상황들로 인해 빌드를 이전 상태로 되돌려야 했지만, 이번 시도를 통해 다음과 같은 핵심 교훈을 얻을 수 있었다.
- 네트워크 및 물리 상태와 UI의 동기화: 단순 이벤트 중심(Event-Driven) 설계와 상시 감시(Tick) 설계의 장단점을 명확히 이해하고, 물리 데이터(Velocity)와 논리 데이터(Gameplay Tag)가 결합할 때의 예외 처리를 체득함.
- 엔진 친화적 시스템 사용의 중요성: 포맷 스트링의 보안 규칙, Slate 시스템의 아틀라스 최적화 원리, UI 전용 머티리얼 도메인 활용 등 언리얼 엔진 5 환경에 맞는 정석적인 개발 표준을 학습함.
- 실패를 통한 디버깅 능력 향상: Accessed None과 같은 치명적인 Null 포인터 경고와 무한 루프 위험성을 직접 마주하며, 런타임 안정성을 확보하기 위한 방어적 프로그래밍의 중요성을 깊이 깨달음.
============
============
<사망 및 리스폰 UI 시스템 구현 및 생명주기(Lifecycle) Desync 버그 수정 (07.18)>
1. 작업 흐름 및 설계 방향 (Thought Process)
- 입력 및 UI 통제권의 컨트롤러 이관: 사망 시 캐릭터(Pawn) 레이어는 래그돌 물리 연산이 들어가고 소멸되므로 가변적임. 따라서 절대 파괴되지 않는 안정적인 객체인 AParcelPlayerController가 사망 HUD(WBP_InGameDeadHUD) 및 ESC 일시정지 메뉴의 생명주기를 전담하도록 설계함.
- 로컬 카운트다운 방식을 통한 최적화: 서버와 매 프레임 동기화하는 틱(Tick) 방식 대신, 사망 시점이 클라이언트에 도달하는 순간 위젯(UParcelInGameDeadHUDWidget) 내부의 로컬 타이머를 구동하여 5초 카운트다운을 처리함. 이를 통해 서버 대역폭을 절약하고 숫자가 바뀔 때마다 즉각적인 UI 애니메이션(두근거리는 연출)을 트리거할 수 있는 성능적 이점을 확보함.
- UI 전용 인풋 모드에서의 ESC 예외 처리: 인풋 모드가 UI Only로 잠기는 사망 화면 내에서 Enhanced Input이 마비되는 현상을 방지하기 위해, 위젯의 슬레이트 레이어인 NativeOnKeyDown을 오버라이드하여 ESC 키 입력을 컨트롤러의 ToggleInGameMenu()로 직접 중계하는 하이재킹 가드를 구축함.
2. 단계별 구현 과정
- 1단계 (C++ 네트워크 채널 개설): AParcelPlayerController에 신뢰성 있는 패킷 전송을 위해 Client, Reliable 속성의 RPC 함수(Client_NotifyDeath, Client_NotifyRespawn)를 개설하여 서버 판정과 클라이언트 UI의 연결고리를 생성함.
- 2단계 (C++ 위젯 베이스 클래스 제작): 1초 주기로 정수 값을 감소시키고, 값이 변할 때마다 블루프린트로 연출 신호를 보낼 UParcelInGameDeadHUDWidget 네티브 클래스를 구현함.
- 3단계 (WBP 애니메이션 바인딩): Txt_CountdownNumber 위젯의 Render Transform -> Transform -> Scale 속성을 제어하는 Anim_Pulse 애니메이션을 제작하고, C++ 상속 이벤트(K2_OnCountdownChanged)와 연동하여 숫자가 바뀔 때마다 팽창 후 복원되는 연출을 구현함.
- 4단계 (리스폰 및 인풋 모드 복구): 부활 완료 시점을 컨트롤러의 OnPossess와 클라이언트 빙의 확정 지점인 AcknowledgePossession에서 이중으로 감지하여 사망 HUD를 제거하고 일반 인게임 HUD와 Game Only 인풋 모드를 복구하도록 함.
3. 핵심 문제점 및 원인 분석, 디버깅 과정 (Debugging Notes)
오늘 테스트베드(DebugSuicide 콘솔 치트) 환경에서 시스템을 검증하는 도중 총 3가지의 치명적인 멀티플레이어 생명주기 버그가 발생하였으며, 이를 순차적으로 분석하고 해결함.
❌ 버그 1: 체력이 0이 되어도 래그돌 컴포넌트 미작동 및 사망 UI 출력 실패
- 원인 분석: 기존 코드는 서버에서 캐릭터가 컨트롤러에 빙의되는 시점인 PossessedBy()에서 HealthComponent의 사망 델리게이트를 PlayerState에 바인딩하고 있었음. 그러나 언리얼 네트워크 아키텍처상 PossessedBy가 터지는 찰나에는 PlayerState가 아직 복제 및 할당되지 않아 GetPlayerState()가 nullptr을 반환함. 이로 인해 사망 바인딩 자체가 유실되는 '유령 바인딩' 현상이 발생함.
- 해결 방법: 컴포넌트 생명주기가 확실히 보장되는 AParcelCharacter::BeginPlay() 시점으로 바인딩 위치를 변경함. 캐릭터가 사망 신호를 받으면 직접 Ragdoll을 켜고 Dead 상태 태그를 주입한 뒤, PlayerState->HandleDeath()로 이벤트를 전파하는 안정적인 사망 체인을 구축함.
❌ 버그 2: 리스폰 완료 후에도 UI의 체력이 0으로 유지되고 'Dead' 상태 아이콘이 잔류하는 현상
- 원인 분석: 서버에서 RestartPlayer가 실행되면 구세대 캐릭터를 버리고 새로운 캐릭터 액터를 스폰함. 그러나 클라이언트가 부활 RPC(Client_NotifyRespawn)를 수신하는 순간의 미세한 네트워크 복제 시차(Replication Latency)로 인해, GetOwningPlayerPawn()이 월드에서 파괴 중인 "구세대 사망 캐릭터"의 포인터를 일시적으로 반환함. HUD의 자동 결합 시스템(TryBindUIEvents)이 이 시체 컴포넌트를 정상 액터로 오인하여 바인딩을 완료해 버림으로써 UI 화면이 과거 데이터로 굳어버리는 트랩이 발생함.
- 해결 방법:
- TryBindUIEvents 내부 컴포넌트 추적 단계에 CheckDeadComp->IsDead() 필터링 가드를 세워, 구세대 캐릭터일 경우 바인딩을 거부하고 새 캐릭터가 완전히 복제될 때까지 0.1초 리트라이 타이머를 계속 돌리도록 처리함.
- 클라이언트가 새로운 신체를 완전히 수신하고 제어권을 획득한 100% 안전한 확정 지점인 플레이어 컨트롤러의 AcknowledgePossession() 오버라이드를 장착하여, 이 타이밍에 HUD 리바인딩 함수(RequestRebindPlayerEvents)를 재구동하도록 이중 백업을 침.
❌ 버그 3: [최종 병목] 부활 후 조작은 가능하나 동일 액터(BP_ParcelCharacter_C_0)가 '좀비 상태'로 재활용되는 현상
- 원인 분석: 버그 2의 철저한 UI 가드를 세웠음에도 로그 상에서 여전히 이전 캐릭터 이름으로 점프와 달리기가 기록됨. 원인은 기존 캐릭터에 부착되어 있던 URagdollComponent 내부에 '기절 후 5초 뒤 강제 기상'을 처리하는 자동 복구 타이머(AttemptAutoRecovery)가 서버 전용으로 돌고 있었기 때문임. 언리얼 순정 함수인 AGameMode::RestartPlayer는 기존 컨트롤러가 쥐고 있던 Pawn 객체를 자동으로 완전히 소멸(Destroy)시키지 않음. 결국 시체가 맵에 남아 스폰 구역 블로킹을 일으키거나 세션 포인터가 꼬인 상태에서, 5초 뒤 기상 타이머가 시체를 강제로 벌떡 일으켜 세워 조작만 가능한 '데이터가 오염된 좀비 액터 재활용 상태'를 유발함.
- 해결 방법: 서버가 부활 프로세스를 밟기 직전 지점인 URespawnComponent::DoRespawnPlayer 내부에서 현재 컨트롤러가 소유한 폰의 존재 여부를 체크하고, OldPawn->Destroy()를 명시적으로 실행하여 월드에서 구세대 액터 및 내부 타이머 장치들을 완전히 강제 철거함.
4. 결론 및 회고
- 멀티플레이어 UI 및 게임 루프 개발 시, 데이터의 소유권(Ownership)과 객체의 소멸 시점(Lifecycle)을 명확히 제어하지 않으면 랜선을 타고 기괴한 Desync(동기화 오류)가 발생할 수 있음을 깊이 깨달음.
- 특히 RestartPlayer 시 기존 캐릭터 액터의 강제 Destroy() 처리와 클라이언트 측의 AcknowledgePossession 타이밍 가드는 좀비 액터 생성 및 오인 바인딩을 막는 멀티플레이어 환경의 필수 정석 패턴임을 습득함.
- 수술 공사를 마친 결과, 사망 시 래그돌 전환 ➔ 로컬 타이머 기반 심장 박동 카운트다운 UI 출력 ➔ ESC 가드 작동 ➔ 부활 시 기존 시체 완전 소멸 및 HP 100%의 청정한 신규 캐릭터 스폰 ➔ HUD 리바인딩 완성이 하나의 유기적인 톱니바퀴처럼 에러 없이 완벽하게 구동됨을 확인함.
// Fill out your copyright notice in the Description page of Project Settings.
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/PlayerController.h"
#include "ParcelPlayerController.generated.h"
class UParcelInGameDeadHUDWidget;
class UParcelInGameESCMenuWidget;
/**
* 담당자: 김로운
*/
UCLASS()
class PARCEL_KNIGHT_API AParcelPlayerController : public APlayerController
{
GENERATED_BODY()
public:
AParcelPlayerController();
// [Client]
UFUNCTION(Client, Reliable)
void Client_NotifyDeath();
// [Client]
UFUNCTION(Client, Reliable)
void Client_NotifyRespawn();
UFUNCTION(BlueprintCallable, Category = "ParcelUI")
void ToggleInGameMenu();
protected:
virtual void OnPossess(APawn* InPawn) override;
virtual void BeginPlay() override;
virtual void SetupInputComponent() override;
// [Editor] HUD 위젯 클래스 지정
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "ParcelUI")
TSubclassOf<UUserWidget> HUDWidgetClass;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "ParcelUI")
TSubclassOf<UParcelInGameDeadHUDWidget> DeadHUDWidgetClass;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "ParcelUI")
TSubclassOf<UParcelInGameESCMenuWidget> ESCMenuClass;
// HUD 위젯 인스턴스
UPROPERTY(Transient, BlueprintReadOnly, Category = "ParcelUI")
TObjectPtr<UUserWidget> HUDWidgetInstance;
UPROPERTY(Transient, BlueprintReadOnly, Category = "ParcelUI")
TObjectPtr<UParcelInGameDeadHUDWidget> DeadHUDWidgetInstance;
UPROPERTY(Transient, BlueprintReadOnly, Category = "ParcelUI")
TObjectPtr<UParcelInGameESCMenuWidget> ESCMenuRef;
};
#include "Core/ParcelPlayerController.h"
#include "ParcelLog.h"
#include "UI/ParcelInGameDeadHUDWidget.h"
#include "UI/ParcelInGameESCMenuWidget.h"
#include "Core/ParcelCheatManager.h"
#include "Blueprint/UserWidget.h"
DEFINE_LOG_CATEGORY(LogParcelPlayerController);
AParcelPlayerController::AParcelPlayerController()
{
CheatClass = UParcelCheatManager::StaticClass();
}
void AParcelPlayerController::BeginPlay()
{
Super::BeginPlay();
if (IsLocalController())
{
FInputModeGameOnly InputMode;
SetInputMode(InputMode);
bShowMouseCursor = false;
// HUD
if (IsLocalController())
{
if (HUDWidgetClass)
{
HUDWidgetInstance = CreateWidget<UUserWidget>(this, HUDWidgetClass);
if (HUDWidgetInstance)
{
HUDWidgetInstance->AddToViewport();
}
}
}
}
}
void AParcelPlayerController::SetupInputComponent()
{
Super::SetupInputComponent();
}
void AParcelPlayerController::OnPossess(APawn* InPawn)
{
Super::OnPossess(InPawn);
Client_NotifyRespawn();
}
void AParcelPlayerController::Client_NotifyDeath_Implementation()
{
if (!IsLocalController()) return;
CONTROLLER_LOG(All, TEXT("[UI] 로컬 플레이어 사망 감지 - HUD 교체 작업을 시작합니다."));
// Collapse InGameHUD
if (HUDWidgetInstance && HUDWidgetInstance->IsInViewport())
{
HUDWidgetInstance->SetVisibility(ESlateVisibility::Collapsed);
}
// Close ESCMenu
if (ESCMenuRef && ESCMenuRef->IsInViewport())
{
ESCMenuRef->K2_OnMenuCloseStarted();
ESCMenuRef = nullptr;
}
// Dead HUD Widget
if (DeadHUDWidgetClass && !DeadHUDWidgetInstance)
{
DeadHUDWidgetInstance = CreateWidget<UParcelInGameDeadHUDWidget>(this, DeadHUDWidgetClass);
}
if (DeadHUDWidgetInstance)
{
if (!DeadHUDWidgetInstance->IsInViewport())
{
DeadHUDWidgetInstance->AddToViewport(200);
}
// Start CountDown, Set Input Focus and Mouse Activate
DeadHUDWidgetInstance->StartDeathCountdown(5);
FInputModeUIOnly InputMode;
InputMode.SetWidgetToFocus(DeadHUDWidgetInstance->TakeWidget());
InputMode.SetLockMouseToViewportBehavior(EMouseLockMode::DoNotLock);
SetInputMode(InputMode);
bShowMouseCursor = true;
}
}
void AParcelPlayerController::Client_NotifyRespawn_Implementation()
{
if (!IsLocalController()) return;
CONTROLLER_LOG(All, TEXT("[UI] 로컬 플레이어 리스폰 완료 - 일반 HUD로 복구합니다."));
// Collapse DeadHUD
if (DeadHUDWidgetInstance && DeadHUDWidgetInstance->IsInViewport())
{
DeadHUDWidgetInstance->RemoveFromParent();
DeadHUDWidgetInstance = nullptr;
}
// ESCMenuRef
if (ESCMenuRef && ESCMenuRef->IsInViewport())
{
ESCMenuRef->K2_OnMenuCloseStarted();
ESCMenuRef = nullptr;
}
// InGameWidget
if (HUDWidgetInstance)
{
HUDWidgetInstance->SetVisibility(ESlateVisibility::Visible);
}
FInputModeGameOnly InputMode;
SetInputMode(InputMode);
bShowMouseCursor = false;
}
void AParcelPlayerController::ToggleInGameMenu()
{
if (!IsLocalController()) return;
// ESC 메뉴 닫을 때 애니메이션 재생
if (ESCMenuRef && ESCMenuRef->IsValidLowLevel() && ESCMenuRef->IsInViewport())
{
ESCMenuRef->K2_OnMenuCloseStarted();
ESCMenuRef = nullptr;
// [예외] 사망 상태에서 일시정지를 닫았다면, 포커스를 다시 Dead HUD로 복구
if (DeadHUDWidgetInstance && DeadHUDWidgetInstance->IsInViewport())
{
FInputModeUIOnly InputMode;
InputMode.SetWidgetToFocus(DeadHUDWidgetInstance->TakeWidget());
SetInputMode(InputMode);
bShowMouseCursor = true;
}
return;
}
// 메뉴
if (ESCMenuClass)
{
ESCMenuRef = CreateWidget<UParcelInGameESCMenuWidget>(this, ESCMenuClass);
if (ESCMenuRef)
{
ESCMenuRef->AddToViewport(300);
ESCMenuRef->SetupMenu();
}
}
}
#pragma once
#include "CoreMinimal.h"
#include "Blueprint/UserWidget.h"
#include "Input/Reply.h"
#include "ParcelInGameDeadHUDWidget.generated.h"
class UTextBlock;
/**
*
*/
UCLASS()
class PARCEL_KNIGHT_API UParcelInGameDeadHUDWidget : public UUserWidget
{
GENERATED_BODY()
public:
void StartDeathCountdown(int32 TotalSeconds = 5);
protected:
virtual void NativeConstruct() override;
virtual FReply NativeOnKeyDown(const FGeometry& InGeometry, const FKeyEvent& InKeyEvent) override;
UFUNCTION(BlueprintImplementableEvent, Category = "ParcelUI|Dead")
void K2_OnCountdownChanged(int32 NewCount);
UPROPERTY(meta = (BindWidgetOptional), BlueprintReadOnly, Category = "ParcelUI|Dead")
TObjectPtr<UTextBlock> Txt_CountdownNumber;
private:
// 타이머 내부 함수
void AdvanceCountdown();
FTimerHandle CountdownTimerHandle;
int32 CurrentCount = 5;
};
#include "UI/ParcelInGameDeadHUDWidget.h"
#include "Components/TextBlock.h"
#include "Core/ParcelPlayerController.h"
#include "TimerManager.h"
#include "InputCoreTypes.h"
void UParcelInGameDeadHUDWidget::NativeConstruct()
{
Super::NativeConstruct();
}
void UParcelInGameDeadHUDWidget::StartDeathCountdown(int32 TotalSeconds)
{
CurrentCount = TotalSeconds;
// UI 텍스트 초기화 및 첫 프레임 연출 트리거
if (Txt_CountdownNumber)
{
Txt_CountdownNumber->SetText(FText::AsNumber(CurrentCount));
}
K2_OnCountdownChanged(CurrentCount);
// 1초 간격으로 반복되는 로컬 타이머 구동
GetWorld()->GetTimerManager().ClearTimer(CountdownTimerHandle);
GetWorld()->GetTimerManager().SetTimer(CountdownTimerHandle, this, &UParcelInGameDeadHUDWidget::AdvanceCountdown, 1.0f, true);
}
void UParcelInGameDeadHUDWidget::AdvanceCountdown()
{
CurrentCount--;
if (CurrentCount <= 0)
{
GetWorld()->GetTimerManager().ClearTimer(CountdownTimerHandle);
if (Txt_CountdownNumber)
{
Txt_CountdownNumber->SetText(FText::AsNumber(0));
}
K2_OnCountdownChanged(0);
// 카운트가 다 끝나면 자동 리스폰 처리는 서버가 진행하므로 UI는 대기하네!
return;
}
// 숫자가 바뀔 때마다 텍스트 갱신 및 블루프린트 애니메이션 이벤트 호출!
if (Txt_CountdownNumber)
{
Txt_CountdownNumber->SetText(FText::AsNumber(CurrentCount));
}
K2_OnCountdownChanged(CurrentCount);
}
FReply UParcelInGameDeadHUDWidget::NativeOnKeyDown(const FGeometry& InGeometry, const FKeyEvent& InKeyEvent)
{
// [보완책 적용] UI Only 모드에서 ESC 키가 누락되는 트랩 방어
if (InKeyEvent.GetKey() == EKeys::Escape)
{
if (AParcelPlayerController* PC = Cast<AParcelPlayerController>(GetOwningPlayer()))
{
PC->ToggleInGameMenu();
return FReply::Handled(); // 입력 처리 완료 선언
}
}
return Super::NativeOnKeyDown(InGeometry, InKeyEvent);
}
// Fill out your copyright notice in the Description page of Project Settings.
#include "Core/ParcelPlayerState.h"
#include "Core/PlayerStatComponent.h"
#include "Core/InventoryComponent.h"
#include "Core/CustomizationComponent.h"
#include "Core/ParcelGameInstance.h"
#include "Core/ParcelGameMode.h"
#include "Core/RespawnComponent.h"
#include "Core/ParcelPlayerController.h"
// ========================= 초기화 =========================
AParcelPlayerState::AParcelPlayerState()
{
PlayerStatComp = CreateDefaultSubobject<UPlayerStatComponent>("PlayerStatComponent");
InventoryComp = CreateDefaultSubobject<UInventoryComponent>("InventoryComponent");
CustomizationComp = CreateDefaultSubobject<UCustomizationComponent>("CustomizationComponent");
}
void AParcelPlayerState::BeginPlay()
{
Super::BeginPlay();
// 서버에서만 초기화 — Items는 복제되어 클라이언트에 자동 전달됨
// NOTE: 현재는 호스트(리슨서버) 전용. 원격 클라이언트의 로드아웃은
// PlayerController의 Server RPC로 전달받아야 함 (TODO)
if (HasAuthority())
{
if (UParcelGameInstance* GI = GetGameInstance<UParcelGameInstance>())
{
InventoryComp->InitFromGameInstance(GI);
CustomizationComp->InitFromGameInstance(GI);
}
}
}
// ========================= 조회 =========================
int32 AParcelPlayerState::GetPersonalScore() const
{
return PlayerStatComp->GetPersonalScore();
}
int32 AParcelPlayerState::GetSuccessCount() const
{
return PlayerStatComp->GetSuccessCount();
}
int32 AParcelPlayerState::GetFailCount() const
{
return PlayerStatComp->GetFailCount();
}
int32 AParcelPlayerState::GetDeathCount() const
{
return PlayerStatComp->GetDeathCount();
}
// ========================= 점수 =========================
void AParcelPlayerState::AddScore(int32 Amount)
{
PlayerStatComp->AddScore(Amount);
}
// ========================= 배달 판정 =========================
void AParcelPlayerState::OnDeliverySuccess()
{
PlayerStatComp->OnDeliverySuccess();
}
void AParcelPlayerState::OnDeliveryFail()
{
PlayerStatComp->OnDeliveryFail();
}
// ========================= 사망 / 부활 =========================
void AParcelPlayerState::HandleDeath()
{
PlayerStatComp->OnDeath();
// [UI] [Server] : 로컬 플레이어 컨트롤러를 찾아서 클라이언트 RPC 호출
if (AParcelPlayerController* PC = Cast<AParcelPlayerController>(GetPlayerController()))
{
PC->Client_NotifyDeath();
}
if (AParcelGameMode* GM = GetWorld()->GetAuthGameMode<AParcelGameMode>())
{
URespawnComponent* RC = GM->GetRespawnComponent();
RC->RespawnPlayerAfterDelay(GetPlayerController(), RC->ReviveDelay);
}
}
void AParcelPlayerState::Client_GrantReward_Implementation(int32 RewardAmount)
{
if (UParcelGameInstance* GI = GetGameInstance<UParcelGameInstance>())
GI->AddMoney(RewardAmount);
}
// ========================= 커스터마이징 =========================
UInventoryComponent* AParcelPlayerState::GetInventoryComponent() const
{
return InventoryComp;
}
UCustomizationComponent* AParcelPlayerState::GetCustomizationComponent() const
{
return CustomizationComp;
}
void AParcelPlayerState::EquipSkin(FGameplayTag SkinTag)
{
if (UParcelGameInstance* GI = GetGameInstance<UParcelGameInstance>())
GI->EquipSkin(SkinTag);
CustomizationComp->EquipSkin(SkinTag);
}
void AParcelPlayerState::EquipTitle(FGameplayTag TitleTag)
{
if (UParcelGameInstance* GI = GetGameInstance<UParcelGameInstance>())
GI->EquipTitle(TitleTag);
CustomizationComp->EquipTitle(TitleTag);
}
void AParcelPlayerState::EquipEffect(FGameplayTag EffectTag)
{
if (UParcelGameInstance* GI = GetGameInstance<UParcelGameInstance>())
GI->EquipEffect(EffectTag);
CustomizationComp->EquipEffect(EffectTag);
}
UFUNCTION()
void HandleCharacterDeath();
void AParcelCharacter::HandleCharacterDeath()
{
PLAYER_LOG(All, TEXT("[사망 체인] 캐릭터 사망 로직이 정상 가동됩니다."));
// Ragdoll 활성화
if (RagdollComp)
{
RagdollComp->StartRagdoll();
}
// Dead 상태 태그
if (HasAuthority() && PlayerStateComp)
{
PlayerStateComp->AddStateTag(FGameplayTag::RequestGameplayTag(TEXT("Character.State.Dead")));
}
// PlayerState로 넘김
if (HasAuthority())
{
if (AParcelPlayerState* PS = GetPlayerState<AParcelPlayerState>())
{
PS->HandleDeath();
}
}
}
public:
// 로컬 플레이어와 데이터를 바인딩
void TryBindUIEvents();
// 리스폰 완료 시 재결합
void RequestRebindPlayerEvents();
void UParcelHUDWidget::RequestRebindPlayerEvents()
{
if (GetWorld())
{
GetWorld()->GetTimerManager().ClearTimer(RetryBindTimerHandle);
TryBindUIEvents();
}
}