지난 글에서 인게임 플레이어 목록을 붙였다. 이번에는 장비 패널에 들고 있는 도구를 띄우고, 스테이지를 클리어하면 Result 화면으로 넘어간 뒤 세션을 정리하고 메뉴로 돌아오는 데까지 연결했다. 방에서 게임을 시작할 때 이동할 맵도 Prototype 으로 옮겼다.
| 주제 | 핵심 |
|---|---|
| 도구 표시 | 복제는 되지만 변경 알림이 없는 값은 Binding 으로 읽는다 |
| 클리어 감지 | 계산하지 말고 GameMode 가 확정한 상태를 반영한다 |
| 화면 전환 | Screen 레이어 위에 HUD 레이어가 있다 |
| 세션 종료 | 완료 이벤트를 받고 나서 이동한다 |
들고 있는 도구는 UCWWorkerComponent 가 갖고 있고, 이 컴포넌트는 ACWPlayerCharacter 에 붙어 있다. 그래서 위젯이 Pawn 을 거쳐 컴포넌트를 잡아야 한다.
문제는 HUD 가 만들어지는 시점이다. HUD 는 UCWUIBootstrapComponent 가 PlayerController 의 BeginPlay 에서 생성한다. 그 시점에 Pawn 이 아직 possess 되지 않았을 수 있다. 방에서 인게임으로 seamless travel 로 넘어오면 더 불확실하다.
Construct 에서 한 번만 캐싱하면 그때 null 이었을 경우 그 뒤로 영영 안 채워진다. 그래서 Binding 안에서 지연 캐시를 하도록 했다.
Binding 함수
→ 캐시가 유효한가?
아니오 → GetOwningPlayerPawn → Cast → 캐시 시도
→ 유효하면 값 반환, 아니면 기본값
플레이어 목록 ViewModel 에서도 같은 계열의 문제를 만났었다. "초기화 시점에 다 있을 것"을 가정하면 안 된다는 게 이 프로젝트에서 반복해서 나오는 교훈이다.
HeldTool 은 이렇게 선언돼 있다.
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Replicated, Category = "Work")
ECWToolType HeldTool = ECWToolType::None;
Replicated 지만 ReplicatedUsing 이 아니다. 값이 도착해도 알려주는 이벤트가 없다.
이런 값은 이벤트 기반으로 UI 를 갱신할 수 없다. 그래서 도구 이름은 델리게이트가 아니라 Binding 으로 매 프레임 읽는 쪽을 택했다. 문자열 하나라 비용도 무시할 수준이다.
정리하면 이렇게 갈린다.
| 값의 형태 | UI 갱신 방식 |
|---|---|
ReplicatedUsing + 델리게이트 |
이벤트 구독 |
Replicated 만 |
Binding 으로 폴링 |
플레이어 목록에서 PlayerId 때문에 고생한 것도 같은 뿌리다. RepNotify 가 없는 복제 값은 늦게 도착해도 아무도 안 알려준다.
장비 패널에 페인트 잔여량 표시가 있었는데 이번에 지웠다.
Result 화면으로 넘어가려면 "스테이지가 끝났다"를 알아야 한다.
처음엔 ViewModel 이 목표 배열을 돌면서 전부 완료됐는지 계산하려고 했다. 그런데 ACWGameState 를 열어보니 이미 다 있었다.
ECWStageState::Cleared // enum 값
StageState ReplicatedUsing = OnRep_StageState // 복제 + RepNotify
bool IsStageCleared() const // getter
FCWOnStageStateChangedSignature OnStageStateChanged // BlueprintAssignable
ACWGameMode 가 판정해서 SetStageState() 를 부르고 있었다.
GameMode 가 확정한 값이 정답이다. UI 가 같은 판정을 다시 하면 기준이 두 개가 되고, 나중에 클리어 조건이 바뀌면 한쪽만 고쳐진다. 그래서 계산을 버리고 구독으로 바꿨다.
void UCWObjectiveViewModel::HandleStageStateChanged(
ACWGameState* InGameState,
ECWStageState InStageState
)
{
if (!IsValid(InGameState))
{
return;
}
// GameMode가 확정하고 복제한 스테이지 상태를 그대로 반영
SetIsStageCleared(InStageState == ECWStageState::Cleared);
}
리슨 서버에서는 호스트가 곧 서버라 OnRep_* 가 호출되지 않는다. 그래서 서버는 알림을 직접 내야 한다. 다행히 SetStageState() 는 이미 그렇게 되어 있었다.
void ACWGameState::SetStageState(ECWStageState NewStageState)
{
if (!HasAuthority() || StageState == NewStageState)
{
return;
}
StageState = NewStageState;
OnStageStateChanged.Broadcast(this, StageState);
}
이게 없었으면 클라이언트에서만 Result 가 뜨고 호스트에서는 안 떴을 것이다. 대기실에서 준비 상태를 만들 때 똑같은 함정을 밟은 적이 있어서, 이번엔 검증 항목에 "호스트 화면에서도 Result 가 뜨는지"를 따로 넣어뒀다.
ViewModel 에 bIsStageCleared 를 추가하면서 손봐야 할 곳이 네 군데였다.
BindGameState() 구독 + IsStageCleared() 로 현재 값 즉시 반영
UnbindGameState() 구독 해제
Uninitialize() SetIsStageCleared(false)
마지막 초기화가 제일 빠뜨리기 쉬운데, 없으면 ViewModel 이 재초기화될 때 이전 스테이지의 true 가 그대로 남는다. 다음 스테이지에 들어가자마자 Result 가 뜬다.
목표 배열도 같은 이유로 Uninitialize() 에서 비운다. 이쪽은 MVVM 매크로가 빈 배열 → 빈 배열을 걸러내기 때문에 최초 초기화 때는 알림이 발생하지 않는다.
클리어를 감지했으니 페이지를 열어야 하는데, 여기서 한 번 막혔다.
이 프로젝트의 화면 위젯은 UCWUIScreenWidget 을 상속하고, 페이지 전환은 RequestOpenPage 로 요청한다. 그런데 HUD 위젯은 Screen 위젯이 아니라서 그 노드가 없다.
다행히 서브시스템 함수가 열려 있었다.
UFUNCTION(BlueprintCallable, Category = "CW|UI")
bool OpenPage(ECWUIPage TargetPage);
BlueprintCallable 이라 HUD 위젯에서 직접 부를 수 있다.
GetOwningLocalPlayer → Get Subsystem (CWUISubsystem) → OpenPage(Result)
OpenPage 가 false 를 반환했다. 인게임 PlayerController 는 InitialPage = None 이라 PageClasses 맵이 비어 있었다. 대기실 컨트롤러에는 화면들이 등록돼 있어서 눈치채지 못했다.
반환값이 bool 인 게 여기서 도움이 됐다. 실패를 조용히 넘기지 않고 잡아낼 수 있었다.
입력 모드는 따로 손댈 게 없었다. Result 가 UIOnly 그룹에 이미 매핑돼 있어서 마우스 커서와 버튼 클릭이 자동으로 동작한다.
페이지는 떴는데 목표 패널과 장비 패널이 그 위에 계속 보였다.
UI Root 의 레이어 구성을 보면 이유가 바로 나온다.
Overlay_ScreenLayer ← Result 가 여기
Overlay_HUDLayer ← 인게임 HUD 가 여기. 위에 그려진다
Overlay_ToastLayer
Overlay_PopupLayer
Overlay_TransitionLayer
Screen 보다 HUD 가 뒤에 있으니 HUD 가 위에 깔린다.
처음엔 "HUD 는 별도 레이어라 페이지를 열어도 안 사라진다"를 정상 동작으로 적어뒀는데, 파괴되지 않는다는 뜻이었지 화면상으로는 그냥 버그였다.
이건 Result 만의 문제가 아니다. 앞으로 인게임 중에 여는 모든 페이지가 HUD 아래로 깔린다. 일시정지 메뉴를 만들 때 같은 문제를 다시 만나게 된다.
서브시스템에 HUD 레이어를 비우는 함수가 있다.
UFUNCTION(BlueprintCallable, Category = "CW|UI|HUD")
void ClearHUDWidgets();
쓰지 않았다. HUD 위젯은 UCWUIBootstrapComponent 가 BeginPlay 에서 한 번만 만든다. 제거하면 되살릴 경로가 없다.
그래서 Collapsed 로 숨겼다. 복구 코드는 넣지 않았는데, 메뉴 맵으로 이동하면 월드가 바뀌면서 UI Root 가 재생성되고 Bootstrap 이 HUD 를 새로 만들기 때문이다. 같은 월드에서 재시작하는 기능이 생기면 그때 필요해진다.
두 가지를 정하는 데 시간을 좀 썼다.
주체 — 처음엔 클리어를 감지하는 WBP_ObjectivePanel 에서 숨기려 했다. 그런데 이 위젯은 WBP_InGameHUD 의 자식이다. 자식이 부모를 숨기는 모양이 된다. HUD 루트가 자기 자신을 접고 페이지를 여는 게 맞다.
순서 — 이게 더 중요했다.
OpenPage(Result)
→ 반환값이 true 일 때만 → Set Visibility (Self, Collapsed)
반대로 하면 OpenPage 가 실패했을 때 HUD 만 사라지고 아무것도 안 뜬다. 화면이 텅 빈다. 3.1 에서 실제로 PageClasses 미등록으로 실패했었으니, 순서를 반대로 짰으면 검은 화면을 놓고 원인을 찾았을 것이다.
무언가를 숨길 때는 대체할 것이 확실히 떴는지 먼저 확인하고 숨긴다.
「로비로 이동」을 누르면 세션을 정리하고 메뉴 맵으로 돌아와야 한다.
EOS 세션 파기는 비동기다. 요청하자마자 이동하면 세션이 남은 채로 화면만 바뀐다. 그러면 목록에 좀비 방이 남아서 다른 사람이 들어가려다 실패한다.
기존 ViewModel 에는 로그인 완료 델리게이트만 있었다. 세션 작업용 디스패처를 추가했다.
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(
FCWOnUISessionOperationCompletedSignature,
ECWSessionOperation, Operation,
bool, bSuccess
);
이게 없으면 "작업 진행 중" 상태값을 동작 트리거로 쓰게 되는데, 로그인에서 이미 한 번 겪은 안티패턴이다.
실패 처리도 남겨뒀다. 처리를 빼자는 이야기가 나왔지만 EOS 요청은 실제로 실패한다. 실패했는데 성공한 것처럼 이동하면 위의 좀비 방이 생긴다. 노드 세 개라 빼도 시간이 안 아껴진다.
메뉴 맵으로 돌아갈 때 ClientTravel 의 이동 방식이 중요하다.
TravelType = TRAVEL_Absolute
TRAVEL_Relative 를 쓰면 호스트에 다시 붙으려 한다. 방을 나가는 게 목적인데 다시 접속하게 된다.
처음엔 위젯에서 GameInstance 를 직접 Cast 해서 경로를 읽으려 했다.
GetGameInstance → Cast to CWGameInstance → MenuMapPath
두 가지가 걸렸다.
첫째, WBP_Result 는 공용 에셋이다. 여기에 개인 테스트 맵 경로를 박으면 팀원이 pull 받았을 때도 내 맵으로 이동한다. 위젯 변수 하나로 모으는 것도 부족한데, 기본값 자체가 .uasset 에 구워지기 때문이다.
둘째, 세션 UI 에서 지켜온 계층 규칙을 깬다.
Widget → ViewModel → GameInstance
다른 화면들은 세션에 접근할 때 전부 ViewModel 을 거치고 있었다. 여기에 Cast 를 하나 넣으면 이 위젯만 두 계층을 동시에 아는 예외가 된다.
그래서 설정값을 GameInstance 로 올리고 ViewModel 이 중계하도록 했다. 개인 경로는 테스트용 GameInstance BP 에만 넣었다. 공용 위젯에도, 공용 C++ 에도 맵 경로가 남지 않는다.
여기서 하나 배웠다. 기존 설정값을 따라 protected 로 뒀는데, ViewModel 은 GameInstance 의 파생 클래스가 아니다. public getter 가 없으면 값을 읽는 경로 자체가 없다. getter 가 선택이 아니라 필수였다.
이번 작업에서 붙인 구독이 여러 개다.
Result 화면 OnSessionOperationCompleted (세션 완료)
WBP_InGameHUD bIsStageCleared (클리어 상태)
플레이어 패널 GameState 의 플레이어 목록 변경
전부 Event Destruct 에서 언바인드하고, ViewModel 은 Uninitialize() 를 부르도록 했다.
안 끊으면 두 가지가 생긴다. 위젯이 파괴됐는데 델리게이트가 참조를 들고 있어 GC 가 되지 않고, 다음 월드에서 다시 초기화될 때 같은 델리게이트에 중복 등록된다. 이벤트 한 번에 핸들러가 두 번 도는 식으로 이상해진다.
ViewModel 쪽은 Initialize() 가 맨 앞에서 Uninitialize() 를 부르도록 해서 중복 등록을 구조적으로 막았다.
void UCWObjectiveViewModel::Initialize(UObject* WorldContextObject)
{
// 재초기화 시 기존 델리게이트가 중복 등록되지 않도록 정리
Uninitialize();
...
}
구독을 만든 곳에서 해제까지 같이 짜두는 게 결국 제일 싸다. 나중에 찾으면 재현이 안 되는 버그로 온다.
Replicated 와 ReplicatedUsing 은 UI 갱신 방식이 다르다. RepNotify 가 없는 값은 도착해도 알려주지 않으므로 Binding 으로 읽어야 한다.OnRep 을 받지 못한다. 상태를 바꾸는 함수에서 직접 Broadcast 해야 호스트 화면도 갱신된다. 검증도 호스트·클라이언트를 나눠서 해야 한다.
| 팀프로젝트 10. 환경설정 화면과 인게임 ESC 메뉴 설계 (0) | 2026.08.14 |
|---|---|
| 팀프로젝트 09. 클리어 후 방 복귀와 연결이 끊긴 뒤의 정리 (0) | 2026.08.13 |
| 팀프로젝트 07. 리슨 서버에서 "나"를 찾는 법, 인게임 HUD 플레이어 목록 연동 (0) | 2026.08.11 |
| 팀프로젝트 06. 리슨 서버 대기실 만들면서 마주친 문제 (0) | 2026.08.10 |
| 팀프로젝트 05. 목표 HUD를 MVVM으로 연결 (0) | 2026.08.07 |