1. 카운트다운 숫자를 어떻게 모두에게 똑같이 보이게 할건지
2. phase 바뀔 때 UI 켜고 끄는 시스템 어디에 둘건지
3. 계속 떠있는 UI 와 잠깐 켜졌다 꺼지는 UI를 어떻게 설계할건지
이 게임의 phase 흐름
Warmup -> AssignRole -> Scouting -> InGame -> GameOver
- 정찰 안내 배너 : 맵을 둘러보세요! -> 정찰 내내 떠 있음
- 카운트다운 : 오른쪽 위 숫자 정찰(60초)과 본게임(300초)에서 공용
- 게임 시작 팝업 : 게임 시작! 본게임 진입 순간 3초만
1. 카운트다운에서 종료 시각 복제
각 클라이언트가 위젯에서 60초 세팅하고 1초마다 스스로 깎는 식으로 짰더니 클라마다 미묘하게 달라짐
숫자 대신 이 페이즈가 끝날 서버 시각을 하나 복제해두고 각 클라가 매번 (종료 시각 − 지금 서버 시각)으로 남은 시간을 스스로 계산하게 하는 방식 따라함
// FDGameState.h
UPROPERTY(Replicated, BlueprintReadOnly)
float PhaseEndServerTime = 0.f;
UFUNCTION(BlueprintPure, Category = "Match")
float GetRemainingPhaseTime() const;
// FDGameState.cpp
float AFDGameState::GetRemainingPhaseTime() const
{
const float Remaining = PhaseEndServerTime - GetServerWorldTimeSeconds();
return FMath::Max(Remaining, 0.f);
}
서버는 페이즈를 시작할 때 종료 시각을 딱 한 번 찍음
// FDGameMode.cpp - StartScouting / StartInGame 각각
FDGameState->PhaseEndServerTime = FDGameState->GetServerWorldTimeSeconds() + ScoutTime;
핵심은 GetServerWorldTimeSeconds()
이건 엔진이 제공하는 서버와 클라가 서로 보정해서 공유하는 시계
서버가 이 시계로 종료 시각을 찍고 클라가 이 시계로 뺄셈을 하니 둘의 기준이 맞음
ex. 서버시간이 1000초면 정찰이 60초니까 이 페이즈는 1060초에 끝남
-> PhaseEndSeverTime = 1060
클라가 이걸 받음 그리고 화면에 카운트 다운 그릴 때 현 서버시간에서 빼서 적음
ex. 서버시간이 1035면 1060 - 1035 = 25초
2. 페이즈 UI 어디에 둘지
Phase가 gamestate에 있으니 UI 켜고 끄는 것도 거기에 둠
void AFDGameState::UpdatePhaseUI()
{
APlayerController* PC = GetWorld() ? GetWorld()->GetFirstPlayerController() : nullptr;
if (!PC || !PC->IsLocalController()) return; // 데디 서버엔 로컬 PC가 없어 걸러짐
// 정찰 배너: Scouting일 때만 존재
const bool bWantBanner = (CurrentPhase == EMatchPhase::Scouting);
if (bWantBanner && !ScoutingBannerWidget && ScoutingBannerWidgetClass)
else if (!bWantBanner && ScoutingBannerWidget)
// 카운트다운: Scouting 또는 InGame일 때 존재
const bool bWantCountdown = (CurrentPhase == EMatchPhase::Scouting
|| CurrentPhase == EMatchPhase::InGame);
if (bWantCountdown && !CountdownWidget && CountdownWidgetClass)
else if (!bWantCountdown && CountdownWidget)
}
정찰 시작 이벤트가 오면 배너를 켜라처럼 이벤트마다 명령을 흩뿌리는 대신
지금 phase가 이거니까 위젯 상태는 이래야 한다를 매번 통째로 맞춤
카운트다운 위젯은 정찰, 본게임이 공유하므로 정찰 60초가 끝나고 본게임 300초로 넘어가도 위젯은 그대로 살아 있고 PhaseEndServerTime만 새 값으로 덮어써져 숫자가 자동으로 바뀜
3. 리슨 서버 문제
OnRep은 복제를 받는 쪽에서만 불림
GameMode가 CurrentPhase를 바꿔도 리슨 호스트의 GameState에선 OnRep_CurrentPhase가 자동으로 안 불림
원격 클라는 복제받아 잘 불리는데 호스트만 페이즈 UI가 안 바뀜
값 변경과 수동 OnRep을 함수로 캡슐화
GameMode는 페이즈를 네 곳에서 바꾼다(AssignRole, Scouting, InGame, GameOver)
매번 CurrentPhase = X; OnRep_CurrentPhase();를 두 줄씩 반복하면 하나라도 빠뜨리기 쉬우니까
그래서 이 둘을 SetPhase 하나로 묶음
void AFDGameState::SetPhase(EMatchPhase NewPhase)
{
CurrentPhase = NewPhase;
OnRep_CurrentPhase();
}
4. UI 상태형과 이벤트형
| 상태형 | 이벤트형 |
| phase 상태 | 시간(타이머) |
| Scouting인 동안 존재 | InGame 진입 순간 3초만 |
| phase가 벗어나면 제거 | 타이머가 스스로 제거 |
게임 시작 팝업 InGame 내내 떠 있으면 안 되고 진입이라는 사건을 잠깐 알리고 끝나야 함
그래서 phase 조건 대신 타이머로 수명을 관리
// InGame 진입 순간 생성하고, 3초 뒤 타이머가 스스로 제거
if (CurrentPhase == EMatchPhase::InGame && !StartBannerWidget && StartBannerWidgetClass)
{
StartBannerWidget = CreateWidget<UUserWidget>(PC, StartBannerWidgetClass);
if (StartBannerWidget)
{
StartBannerWidget->AddToViewport();
GetWorldTimerManager().SetTimer(
StartBannerTimerHandle,
[this]()
{
if (StartBannerWidget)
{
StartBannerWidget->RemoveFromParent();
StartBannerWidget = nullptr;
}
},
3.f, false);
}
}
5. 에디터 작업
위젯 세 개 생성
- WBP_ScoutingBanner : Canvas Panel에 Text Block 하나 - 맵을 둘러보세요
- WBP_StartBanner : 같은 방식 - Gamestart
- WBP_Countdown : Text Block 하나에 바인딩을 걸어 실시간 숫자 표시
카운트다운 바인딩
Text 속성 옆 Bind 버튼 -> Create Binding으로 함수 그래프를 열고, Get Game State -> (Pure) Cast To BP_FDGameState -> Get Remaining Phase Time -> Round(반올림) -> To Text -> Return Value로 연결

Bind는 UMG가 매 프레임 이 함수를 자동으로 다시 물어봐 글자에 반영하는 기능
GameState에 연결
- BP_FDGameState의 Details -> UI 카테고리에서 세 위젯 클래스 변수(Scouting Banner / Countdown / Start Banner)에 각 WBP를 할당
'프로젝트 > Funny Or Die (멀티플레이 술래잡기 게임)' 카테고리의 다른 글
| PlayerStart 태그로 역할별 위치 분리 (텔레포트) (0) | 2026.07.18 |
|---|---|
| [트러블슈팅] 리슨 서버에서 클라이언트만 인벤토리 UI가 갱신되지 않음 (0) | 2026.07.17 |
| [트러블슈팅] EOS 세션 연동 (0) | 2026.07.15 |
| 인벤토리 UI 및 아이템 획득 알림 (0) | 2026.07.14 |
| 조준 표시 개선 (디버그 구체 -> 데칼 + 크로스헤어) (0) | 2026.07.13 |
