오늘 한 작업을 정리해보았다.
1. 게임을 시작하면 MainMenuLevel 레벨에 카메라를 세팅해서 카메라는 정지되어있고 배경 레벨을 볼 수 있다. 'Parcel Knight' 라는 게임 타이틀이 큰 글씨로 적혀있고, 그 밑에 메뉴들을 마우스로 선택할 수 있다.
메뉴 버튼들은 다음과 같다.
1) 싱글 플레이 : 리슨 서버이긴 하지만 방을 만들지 않고서도 혼자 플레이할 수 있다.
2) 멀티 플레이 : 'RoomList' 라는 위젯이 MainMenuLevel을 덮어씌우며 위에서 내려오는 연출과 함께 나타난다. 해당 RoomList에는 현재 Steam api 연동을 받아 게임이 연결되어있어 방을 찾고 해당 List를 더블 클릭하여 방에 입장할 수 있다.
3) 옵션 : 기존에 만들어 두었던 OptionsWidget을 재활용한다.
4) 상점 : 상점 기능을 이용할 수 있다. 바인딩하지 않고 우선 버튼만 만들어 둔다.
5) 게임 종료 : 게임을 끈다.
2. RoomListWidget : 상단 왼쪽 위에 'Room List'가 뜨고 그 아래로 반투명한 가로줄로 구분된 List가 뜬다. List에는 호스트가 방을 생성하면 "'해당 호스트의 닉네임' 의 방" 또는 "'호스트 스팀 닉네임'의 방" 형식으로 방 이름이 뜨고, 그 옆에 해당 방에 들어와 있는 플레이어 수가 표시된다. 만들어진 방이 많으면 최대 10개까지 한번에 표시가 되며, 위젯 오른쪽의 드래그바를 이용해서 위 아래로 드래그하며 방을 탐색할 수 있다. 해당 리스트에 마우스를 더블클릭하면, 호스트가 만들어 놓은 방에 접속이 가능하다.
3. 호스트가 만들어 놓은 방을 더블클릭하여 접속하게 되면 LV_DF_Lobby_Stage 레벨로 이동하게 된다. 해당 레벨은 '로비 레벨'이며 해당 로비 레벨에서 플레이어는 자유롭게 하고 싶은 대로 움직이다가, ESC키를 누르면 ParcelLobbyHUDWidget이 출력된다. (이 3단계는 RoomListWidget 작업까지 완료한 후에 다시 얘기를 나누면서 구체화할 예정)
---
1. 사전 작업
상용 멀티플레이 게임 기준 코드 및 설계 검토
1. '싱글 플레이'의 내부적 메커니즘
"싱글 플레이 : 방을 만들지 않고 혼자 플레이할 수 있다."
컴포넌트 상호작용, 태그 시스템, 박스 줍기 등 많은 핵심 로직이 서버/클라이언트 권한 구조(HasAuthority()) 위에서 작동한다. 만약 싱글 플레이 버튼을 눌렀을 때 완전히 세션을 만들지 않고 단순 OpenLevel로 맵을 열어버리면, 네트워크 모드가 단독 로컬 모드로 돌아가서 멀티플레이 전용으로 설계된 C++ 코드들이 꼬이거나 에러를 뱉을 수 있다.
싱글 플레이 버튼을 누르더라도 내부적으로는 NumPublicConnections = 1 및 bIsLANMatch = true 옵션을 주어 "나 혼자만 들어올 수 있는 비공개 리슨 서버 세션"을 생성(CreateSession)한 뒤 인게임 스테이지로 이동시키는 것이 프레임워크 안정성 측면에서 안전하다.
2. Steam 닉네임 표시의 한계 (GetSessionOwnerName)
현재 SessionSearch->SearchResults[Index].Session.OwningUserName을 반환하여 방 이름을 채우도록 되어 있다.
Steam OSS 환경에서 핑(Ping) 지연이나 매칭 타이밍에 따라 엔진이 기본 제공하는 OwningUserName이 간혹 빈 문자열("")로 넘어오거나 깨지는 고질적인 현상이 발생한다.
세션을 생성(CreateSession)하는 시점에 FOnlineSessionSettings에 커스텀 키-값 데이터를 명시적으로 주입하고, 방 목록을 찾을 때 이 값을 읽어오는 방식을 현업에서 표준으로 쓴다.
예: 세션 세팅에 SessionSettings.Set(TEXT("HOST_NAME"), 현재유저이름, ...); 등록 후 검색창에서 파싱.
지금 단계에서는 현재 코드로 먼저 매핑을 진행하되, 추후 방 이름이 비어 나오는 현상이 생기면 커스텀 키 세팅으로 보완할 예정이다.
3. RoomListWidget의 비동기 데이터 갱신 타이밍
FindSessions()는 비동기로 작동하네. 즉, 버튼을 누르는 즉시 방 목록이 나오는 게 아니라 스팀 서버를 다녀오는 데 약간의 시간이 걸리게 된다. 따라서, RoomListWidget이 화면에 나타나면 애니메이션 연출과 동시에 FindSessions()를 호출하고, C++ 델리게이트 신호를 받은 OnSessionsFound(bool bSuccess) 이벤트가 true로 응답하는 그 찰나의 순간에 GetSearchResultCount()를 순회하며 리스트의 가로줄 줄(Row) 위젯들을 스크롤 박스에 동적으로 스폰해야 한다.
4. RoomList 내부 가로줄(Row) 위젯의 고유 인덱스 보존
JoinSession(int32 SessionIndex) 함수는 검색된 결과 배열의 '순서 번호(인덱스)'를 요구하고 있다.
방 목록에 생성될 각각의 가로줄 위젯(예: WBP_RoomListEntry)은 자신이 스팀 검색 결과 중 몇 번째 줄인지 나타내는 int32 SessionIndex 변수를 반드시 Instance Editable 및 Expose on Spawn 속성으로 들고 있어야 한다. 그래야 유저가 특정 방을 더블클릭했을 때, 해당 위젯이 품고 있던 인덱스 번호를 부모창을 거쳐 서브시스템으로 안전하게 던져서 입장(JoinSession)시킬 수 있게 된다.
5. 로비 레벨(LV_DF_Lobby_Stage)로의 안전한 전환 흐름
USessionSubsystem::StartGame에서 ServerTravel(MapPath + "?listen")을 지원하고, OnJoinSessionComplete 시점에 클라이언트가 ClientTravel을 하도록 짜여 있다.
호스트가 방을 생성하고 세션 시작이 완료되면(OnStartSessionComplete), GI->GetPendingMapPath()에 들어있는 주소로 서버 트래블이 발동하게 된다. 따라서 메인 메뉴 위젯에서 방 만들기 버튼을 누를 때 SetMapPath(TEXT("/Game/Maps/LV_DF_Lobby_Stage")) (실제 로비 맵 경로)를 미리 주입해 주는 흐름만 기획서대로 잘 연결해 주면 완성된다.
============
[MainMenu 만들기]
1단계: 메인 메뉴 C++ 컨트롤러 클래스 작성
기존에 만들어진 UParcelSessionWidget을 부모 클래스로 상속받아, 세션 생성·검색 기능과 메인 메뉴의 버튼들(싱글, 멀티, 옵션, 종료)을 네이티브단에서 다이렉트로 결합했다.
#pragma once
#include "CoreMinimal.h"
#include "UI/ParcelSessionWidget.h"
#include "ParcelMainMenuWidget.generated.h"
class UButton;
class UParcelOptionsWidget;
/**
* UParcelMainMenuWidget
* 담당자 : JYW
*/
UCLASS()
class PARCEL_KNIGHT_API UParcelMainMenuWidget : public UParcelSessionWidget
{
GENERATED_BODY()
protected:
virtual void NativeConstruct() override;
// UMG 위젯 버튼 바인딩
UPROPERTY(meta = (BindWidget))
TObjectPtr<UButton> Btn_SinglePlay;
UPROPERTY(meta = (BindWidget))
TObjectPtr<UButton> Btn_MultiPlay;
UPROPERTY(meta = (BindWidget))
TObjectPtr<UButton> Btn_Options;
// 상점은 아직 기믹 미구현이므로 Optional 처리
UPROPERTY(meta = (BindWidgetOptional))
TObjectPtr<UButton> Btn_Shop;
UPROPERTY(meta = (BindWidget))
TObjectPtr<UButton> Btn_ExitGame;
// 재활용 및 생성할 위젯 클래스 정보
UPROPERTY(EditDefaultsOnly, Category = "MainMenu|UI")
TSubclassOf<UParcelOptionsWidget> OptionsWidgetClass;
// 블루프린트 UI 연출용 이벤트
/** 멀티플레이 버튼 클릭 시 RoomList 위젯 슬라이드 다운 애니메이션 재생을 요청 */
UFUNCTION(BlueprintImplementableEvent, Category = "MainMenu|Events")
void K2_OnMultiPlayMenuOpened();
private:
// 버튼 클릭 핸들러 함수
UFUNCTION()
void HandleSinglePlayClicked();
UFUNCTION()
void HandleMultiPlayClicked();
UFUNCTION()
void HandleOptionsClicked();
UFUNCTION()
void HandleShopClicked();
UFUNCTION()
void HandleExitGameClicked();
};
#include "UI/ParcelMainMenuWidget.h"
#include "Components/Button.h"
#include "UI/ParcelOptionsWidget.h"
#include "Kismet/KismetSystemLibrary.h"
#include "ParcelLog.h"
void UParcelMainMenuWidget::NativeConstruct()
{
Super::NativeConstruct();
if (Btn_SinglePlay)
Btn_SinglePlay->OnClicked.AddDynamic(this, &UParcelMainMenuWidget::HandleSinglePlayClicked);
if (Btn_MultiPlay)
Btn_MultiPlay->OnClicked.AddDynamic(this, &UParcelMainMenuWidget::HandleMultiPlayClicked);
if (Btn_Options)
Btn_Options->OnClicked.AddDynamic(this, &UParcelMainMenuWidget::HandleOptionsClicked);
if (Btn_Shop)
Btn_Shop->OnClicked.AddDynamic(this, &UParcelMainMenuWidget::HandleShopClicked);
if (Btn_ExitGame)
Btn_ExitGame->OnClicked.AddDynamic(this, &UParcelMainMenuWidget::HandleExitGameClicked);
}
void UParcelMainMenuWidget::HandleSinglePlayClicked()
{
INGAMEHUD_LOG(Log, TEXT("[Main Menu] 싱글 플레이 모드 진입 시작"));
// HasAuthority 1인 리슨 서버 구동
// [중요] 실제 마무리 단계에서는 맵 이름 정확하게 세팅해서 경로 바꿔줘야 함
SetMapPath(TEXT("/Game/Maps/TestMaps/Testing_DF_Stage01"));
CreateSession(1);
}
void UParcelMainMenuWidget::HandleMultiPlayClicked()
{
INGAMEHUD_LOG(Log, TEXT("[Main Menu] 멀티 플레이 버튼 클릭"));
// 스팀 서버에 방 목록 요청
FindSessions();
K2_OnMultiPlayMenuOpened();
}
void UParcelMainMenuWidget::HandleOptionsClicked()
{
if (!OptionsWidgetClass)
{
INGAMEHUD_LOG(Error, TEXT("[Main Menu] OptionsWidgetClass가 할당되지 않았습니다."));
return;
}
// OptionsWidget
if (UParcelOptionsWidget* OptionsMenu = CreateWidget<UParcelOptionsWidget>(GetOwningPlayer(), OptionsWidgetClass))
{
OptionsMenu->AddToViewport(110);
}
}
void UParcelMainMenuWidget::HandleShopClicked()
{
// 추후 상점 필요 시 여기다 연동
INGAMEHUD_LOG(Warning, TEXT("[Main Menu] 상점 기능은 현재 프로토타입 단계에서 비활성화되어 있습니다."));
}
void UParcelMainMenuWidget::HandleExitGameClicked()
{
INGAMEHUD_LOG(Log, TEXT("[Main Menu] 게임 종료 요청"));
UKismetSystemLibrary::QuitGame(GetWorld(), GetOwningPlayer(), EQuitPreference::Quit, false);
}
2단계: MainMenuLevel 배경 및 카메라 환경 구성 (에디터 작업)
기획서 1번에 적힌 *"카메라는 정지되어 있고 멋진 배경 레벨을 볼 수 있다"*를 가장 오버헤드 없이 정석적으로 구현하는 방법일세.
언리얼 에디터에서 MainMenuLevel이라는 빈 레벨을 하나 생성하고, 맵 구석에 인게임 에셋이나 오브젝트를 배치해서 미니 스튜디오를 구성했다.
액터 배치 패널에서 Cine Camera Actor(시네 카메라 액터) 또는 일반 Camera Actor를 맵에 드래그 앤 드롭하여 카메라 액터를 배치하고, 카메라를 조절해서 각도를 고정시켜 두었다.

MainMenuLevel 맵의 상단 Level Blueprint(레벨 블루프린트) 작업을 시작했다. Get Player Controller 노드를 꺼내와 뷰포트에 배치해 둔 카메라 액터를 선택한 상태로 레벨 블루프린트에서 우클릭하여 'Create a Reference to CameraActor' 노드로 참조를 가져왔고, 플레이어 컨트롤러를 타겟으로 Set View Target with Blend 노드를 호출하고, New View Target 칸에 카메라 액터 참조를 꽂아주었다.


3단계: WBP_MainMenu 위젯 조립 및 연출 (UMG 작업)
이제 C++ 클래스를 상속받아 실제 UI 껍데기를 이쁘게 꾸미고 애니메이션을 입힐 시간이다.
WBP_MainMenu라는 이름으로 ParcelMainMenuWidget을 상속받는 블루프린트를 만들었다.

이후 이벤트 그래프에서 노드 작업을 시작했다.
작업 결과는 다음과 같다.

=================
[RoomList 만들기]
1단계 :
방 목록 전체를 관리할 Widget과 그 내부에 들어갈 가로줄 Entry 위젯, 총 2개의 클래스를 신설했다.
#pragma once
#include "CoreMinimal.h"
#include "Blueprint/UserWidget.h"
#include "ParcelRoomListEntry.generated.h"
class UTextBlock;
class USessionSubsystem;
/**
* UParcelRoomListEntry
* 담당자 : JYW
*/
UCLASS()
class PARCEL_KNIGHT_API UParcelRoomListEntry : public UUserWidget
{
GENERATED_BODY()
public:
UFUNCTION(BlueprintCallable, Category = "RoomList")
void InitializeEntry(int32 InSessionIndex, UParcelRoomListWidget* InOwnerList);
UFUNCTION(BlueprintCallable, Category = "RoomList")
void SelectThisRoom();
UFUNCTION(BlueprintCallable, Category = "RoomList")
void JoinThisRoom();
FORCEINLINE int32 GetSessionIndex() const { return MySessionIndex; }
UFUNCTION(BlueprintImplementableEvent, Category = "RoomList|Visual")
void K2_SetHighlightState(bool bIsHighlighted);
protected:
virtual void NativeConstruct() override;
// UMG 위젯 바인딩
UPROPERTY(meta = (BindWidget))
TObjectPtr<UTextBlock> Txt_RoomName;
UPROPERTY(meta = (BindWidget))
TObjectPtr<UTextBlock> Txt_PlayerCount;
private:
int32 MySessionIndex = -1;
UPROPERTY(Transient)
TObjectPtr<UParcelRoomListWidget> OwnerRoomListWidget;
};
#include "UI/ParcelRoomListEntry.h"
#include "Components/TextBlock.h"
#include "UI/ParcelRoomListWidget.h"
#include "Core/SessionSubsystem.h"
#include "ParcelLog.h"
void UParcelRoomListEntry::NativeConstruct()
{
Super::NativeConstruct();
}
void UParcelRoomListEntry::InitializeEntry(int32 InSessionIndex, UParcelRoomListWidget* InOwnerList)
{
MySessionIndex = InSessionIndex;
OwnerRoomListWidget = InOwnerList;
USessionSubsystem* SS = GetGameInstance() ? GetGameInstance()->GetSubsystem<USessionSubsystem>() : nullptr;
if (!SS) return;
FString OwnerName = SS->GetSessionOwnerName(MySessionIndex);
int32 CurrentPlayers = SS->GetSessionPlayerCount(MySessionIndex);
FString FormattedRoomName = FString::Printf(TEXT("%s 의 방"), *OwnerName);
FString FormattedPlayerCount = FString::Printf(TEXT("%d 명"), CurrentPlayers);
if (Txt_RoomName) Txt_RoomName->SetText(FText::FromString(FormattedRoomName));
if (Txt_PlayerCount) Txt_PlayerCount->SetText(FText::FromString(FormattedPlayerCount));
K2_SetHighlightState(false);
}
void UParcelRoomListEntry::SelectThisRoom()
{
if (OwnerRoomListWidget)
{
OwnerRoomListWidget->SetSelectedEntry(this);
}
}
void UParcelRoomListEntry::JoinThisRoom()
{
if (MySessionIndex == -1) return;
USessionSubsystem* SS = GetGameInstance() ? GetGameInstance()->GetSubsystem<USessionSubsystem>() : nullptr;
if (SS)
{
INGAMEHUD_LOG(Log, TEXT("[Room Entry] 세션 참가 시도 - 인덱스: %d"), MySessionIndex);
SS->JoinSession(MySessionIndex);
}
}
#pragma once
#include "CoreMinimal.h"
#include "UI/ParcelSessionWidget.h"
#include "ParcelRoomListWidget.generated.h"
class UScrollBox;
class UButton;
class UParcelRoomListEntry;
/**
* UParcelRoomListWidget
* 담당자 : JYW
*/
UCLASS()
class PARCEL_KNIGHT_API UParcelRoomListWidget : public UUserWidget
{
GENERATED_BODY()
public:
UFUNCTION(BlueprintCallable, Category = "RoomList")
void RefreshRoomList();
void SetSelectedEntry(UParcelRoomListEntry* NewEntry);
protected:
virtual void NativeConstruct() override;
// UMG 위젯 바인딩
UPROPERTY(meta = (BindWidget))
TObjectPtr<UScrollBox> ScrollBox_Rooms;
UPROPERTY(meta = (BindWidget))
TObjectPtr<UButton> Btn_RefreshRooms;
UPROPERTY(meta = (BindWidget))
TObjectPtr<UButton> Btn_BackToMenu;
UPROPERTY(meta = (BindWidget))
TObjectPtr<UButton> Btn_JoinRoom;
// 데이터 및 팩토리
UPROPERTY(EditDefaultsOnly, Category = "RoomList|UI")
TSubclassOf<UParcelRoomListEntry> RoomEntryClass;
// 블루프린트
UFUNCTION(BlueprintImplementableEvent, Category = "RoomList|Events")
void K2_OnBackToMainMenuStarted();
private:
// 버튼 클릭 핸들러
UFUNCTION() void HandleRefreshRoomsClicked();
UFUNCTION() void HandleBackToMenuClicked();
UFUNCTION() void HandleJoinRoomClicked();
UPROPERTY(Transient)
TObjectPtr<UParcelRoomListEntry> CurrentlySelectedEntry = nullptr;
};
#include "UI/ParcelRoomListWidget.h"
#include "Components/ScrollBox.h"
#include "Components/Button.h"
#include "UI/ParcelRoomListEntry.h"
#include "Core/SessionSubsystem.h"
#include "ParcelLog.h"
void UParcelRoomListWidget::NativeConstruct()
{
Super::NativeConstruct();
if (Btn_RefreshRooms)
Btn_RefreshRooms->OnClicked.AddDynamic(this, &UParcelRoomListWidget::HandleRefreshRoomsClicked);
if (Btn_BackToMenu)
Btn_BackToMenu->OnClicked.AddDynamic(this, &UParcelRoomListWidget::HandleBackToMenuClicked);
if (Btn_JoinRoom)
Btn_JoinRoom->OnClicked.AddDynamic(this, &UParcelRoomListWidget::HandleJoinRoomClicked);
}
void UParcelRoomListWidget::RefreshRoomList()
{
if (!ScrollBox_Rooms) return;
ScrollBox_Rooms->ClearChildren();
CurrentlySelectedEntry = nullptr;
USessionSubsystem* SS = GetGameInstance() ? GetGameInstance()->GetSubsystem<USessionSubsystem>() : nullptr;
if (!SS || !RoomEntryClass) return;
int32 RoomCount = SS->GetSearchResultCount();
for (int32 i = 0; i < RoomCount; ++i)
{
if (UParcelRoomListEntry* NewRow = CreateWidget<UParcelRoomListEntry>(GetOwningPlayer(), RoomEntryClass))
{
NewRow->InitializeEntry(i, this);
ScrollBox_Rooms->AddChild(NewRow);
}
}
}
void UParcelRoomListWidget::SetSelectedEntry(UParcelRoomListEntry* NewEntry)
{
// 1. 기존에 선택되어 불이 켜져있던 녀석이 있다면 불을 꺼줌
if (CurrentlySelectedEntry)
{
CurrentlySelectedEntry->K2_SetHighlightState(false);
}
// 2. 새로운 타겟 위젯 기억
CurrentlySelectedEntry = NewEntry;
// 3. 새로 선택된 녀석에게 "너 대장한테 간택 받았으니 불 켜라!" 하고 신호 전달
if (CurrentlySelectedEntry)
{
CurrentlySelectedEntry->K2_SetHighlightState(true);
}
}
void UParcelRoomListWidget::HandleRefreshRoomsClicked()
{
INGAMEHUD_LOG(Log, TEXT("[Room List] 새로고침 버튼 클릭 -> 스팀 방 재검색 트리거"));
USessionSubsystem* SS = GetGameInstance() ? GetGameInstance()->GetSubsystem<USessionSubsystem>() : nullptr;
if (SS)
{
SS->FindSessions();
}
}
void UParcelRoomListWidget::HandleBackToMenuClicked()
{
INGAMEHUD_LOG(Log, TEXT("[Room List] 뒤로가기 버튼 클릭 -> 메인 메뉴 복귀 연출 개시"));
K2_OnBackToMainMenuStarted(); // 블프단에 슬라이드 업 애니메이션 특명 하사
}
void UParcelRoomListWidget::HandleJoinRoomClicked()
{
// 더블클릭 뿐만 아니라, 1번 클릭으로 강조된 상태에서 하단 버튼을 눌러도 입장 가능하게 함!
if (CurrentlySelectedEntry)
{
INGAMEHUD_LOG(Log, TEXT("[Room List] 하단 버튼을 통해 선택된 세션 입장 처리"));
CurrentlySelectedEntry->JoinThisRoom();
}
else
{
INGAMEHUD_LOG(Warning, TEXT("[Room List] 선택된 방이 없어 입장 버튼이 작동하지 않습니다."));
}
}
단순히 목록만 나열하고 더블클릭하는 것보다, 한 번 클릭해서 어떤 방이 선택되었는지 은은하게 강조(Highlight) 효과를 주고, 하단에 [방 입장], [새로고침], [뒤로가기] 버튼 패널을 구성하는 것이 UX(유저 경험) 측면에서의 개선을 꾀했다. 따라서, "부모 위젯(RoomList)이 현재 자식들(Entry) 중 어떤 녀석이 선택되었는지 포인터로 기억하고 관리하는 매니징 구조"를 구성했다.
2단계 : WBP 위젯 구성 및 연동하기






이후, 메인 메뉴용 게임모드 제작 및 월드 세팅을 했다.
메인 메뉴 전용 게임 모드 생성:
일반 GameMode를 상속받아서, BP_MainMenuGameMode라고 지어주었고,
기본 캐릭터가 스폰되면 시점 구도가 망가지므로 드롭다운을 열어 None으로 비워버렸고, 인게임 HUD가 생성되는 것을 원천 차단하기 위해 이 항목도 None으로 비워버렸다.
MainMenuLevel 월드 세팅(World Settings)에서, GameMode 카테고리 아래에 있는 GameMode Override 칸의 드롭다운을 딸깍 열고, 방금 정화 완료한 BP_MainMenuGameMode를 명시적으로 할당했다.
============================
[로비 레벨 HUD 만들기]
우선 로비 레벨에서 사용할 수 있는 경량형 GameMode를 하나 더 만들 필요가 있어 새롭게 로비용 게임모드를 만들었다.
이후 LobbyHUD 작업을 시작했다.
<ParcelLobbyHUDWidget>
1. ParcelOptionsWidget을 띄울 수 있는 설정 버튼
2. ParcelFriendListWidget을 호출할 수 있는 버튼
3. 현재 인원 Text와 실제로 로비에 접속해있는 인원 표시
4. 실제로 로비에 접속해있는 플레이어의 아이디 혹은 닉네임을 Steam 계정 프로필과 함께 표시
5. 나가기 버튼(로비 접속을 종료하고 메인 메뉴로 돌아가는 버튼)
6. 방장(호스트)만 누를 수 있는 '맵 선택' 버튼. 맵 선택의 경우 저장되어있는 데이터테이블을 받아와서 '에디터 에서 수정'할 수 있도록 했으면 좋겠어. 아직 데이터 테이블이 만들어지지 않았거든.
7. 방장(호스트)만 누를 수 있는 '게임 시작' 버튼
8. 모두가 볼 수 있는 선택된 맵 이미지, 맵 이름
9. "ESC를 눌러 로비 메뉴를 확인하세요" 텍스트
10. 로비 맵에 이동 후 ESC를 누르면 LobbyHUDWidget이 부드러운 효과와 함께 출력됨. 해당 HUD위젯이 띄워진 동안에는 플레이어가 캐릭터를 조작할 수 없고 마우스로 UI만 조작하는 형태.
11. 로비에 들어와있는 플레이어가 볼 수 있는 채팅창, 채팅을 입력할 수 있는 기능. 평상시에는 보이지 않다가 엔터를 누르면 채팅창이 활성화되며 채팅을 입력할 수 있음. 채팅 입력 후에는 채팅창은 사라지지만 채팅 내용 텍스트는 계속 보임.
1단계 : ParcelLobbyHUDWidget
#pragma once
#include "CoreMinimal.h"
#include "Blueprint/UserWidget.h"
#include "Engine/DataTable.h"
#include "ParcelLobbyHUDWidget.generated.h"
class UButton;
class UTextBlock;
class UScrollBox;
class UEditableText;
class UImage;
class UCanvasPanel;
class UWidgetAnimation;
class UParcelLobbyPlayerSlotWidget;
/**
* 맵 스테이지 구조체
*/
USTRUCT(BlueprintType)
struct FParcelMapStageData : public FTableRowBase
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "MapData")
FString StageName;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "MapData")
FString MapPath;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "MapData")
TSoftObjectPtr<UTexture2D> StageThumbnail;
};
/**
* UParcelLobbyHUDWidget
* 담당자 : JYW
*/
UCLASS()
class PARCEL_KNIGHT_API UParcelLobbyHUDWidget : public UUserWidget
{
GENERATED_BODY()
protected:
virtual void NativeConstruct() override;
virtual FReply NativeOnKeyDown(const FGeometry& MyGeometry, const FKeyEvent& InKeyEvent) override;
void SetupLobbyLayout();
void SetMenuVisibleState(bool bNewState);
void SetChatInputInputMode(bool bFocusChat);
UFUNCTION() void HandleOptionsClicked();
UFUNCTION() void HandleFriendsClicked();
UFUNCTION() void HandleLeaveLobbyClicked();
UFUNCTION() void HandleSelectMapClicked();
UFUNCTION() void HandleActionOrStartClicked();
UFUNCTION() void HandleChatTextCommitted(const FText& Text, ETextCommit::Type CommitMethod);
UFUNCTION() void HandleOnSessionDestroyComplete(bool bWasSuccessful);
protected:
UPROPERTY(BlueprintReadOnly, meta = (BindWidget)) TObjectPtr<UCanvasPanel> Canvas_MenuContainer;
UPROPERTY(BlueprintReadOnly, meta = (BindWidget)) TObjectPtr<UTextBlock> Txt_EscPrompt;
UPROPERTY(BlueprintReadOnly, meta = (BindWidget)) TObjectPtr<UButton> Btn_Options;
UPROPERTY(BlueprintReadOnly, meta = (BlueprintReadOnly, BindWidget)) TObjectPtr<UButton> Btn_Friends;
UPROPERTY(BlueprintReadOnly, meta = (BindWidget)) TObjectPtr<UButton> Btn_Leave;
UPROPERTY(BlueprintReadOnly, meta = (BindWidget)) TObjectPtr<UButton> Btn_SelectMap;
UPROPERTY(BlueprintReadOnly, meta = (BindWidget)) TObjectPtr<UButton> Btn_Action;
UPROPERTY(BlueprintReadOnly, meta = (BindWidget)) TObjectPtr<UTextBlock> Txt_ActionPrompt;
UPROPERTY(BlueprintReadOnly, meta = (BindWidget)) TObjectPtr<UTextBlock> Txt_PlayerCount;
UPROPERTY(BlueprintReadOnly, meta = (BindWidget)) TObjectPtr<UTextBlock> Txt_MapName;
UPROPERTY(BlueprintReadOnly, meta = (BindWidget)) TObjectPtr<UImage> Img_MapThumbnail;
UPROPERTY(BlueprintReadOnly, meta = (BindWidget)) TObjectPtr<UScrollBox> ScrollBox_LobbyPlayers;
UPROPERTY(BlueprintReadOnly, meta = (BindWidget)) TObjectPtr<UScrollBox> ScrollBox_ChatLogs;
UPROPERTY(BlueprintReadOnly, meta = (BindWidget)) TObjectPtr<UEditableText> EditableText_ChatInput;
private:
bool bIsMenuOpen = false;
bool bIsReady = false;
protected:
UPROPERTY(EditDefaultsOnly, Category = "Lobby|UI")
TSubclassOf<UParcelLobbyPlayerSlotWidget> PlayerSlotClass;
UFUNCTION(BlueprintImplementableEvent, Category = "Lobby|UI")
void K2_OnMenuStateChanged(bool bIsOpen);
public:
UFUNCTION(BlueprintCallable, Category = "Lobby")
void RefreshLobbyPlayers();
public:
void AddChatLog(const FString& SenderName, const FText& Message);
protected:
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Lobby|Data")
TObjectPtr<UDataTable> MapDataTable;
void HandleOnLobbyMapChanged(int32 NewMapIndex);
private:
int32 LocalCurrentMapIndex = 0;
protected:
UPROPERTY(EditDefaultsOnly, Category = "Lobby|Subsystem")
TSubclassOf<UUserWidget> OptionsWidgetClass;
UPROPERTY(EditDefaultsOnly, Category = "Lobby|Subsystem")
TSubclassOf<class UParcelFriendListWidget> FriendListWidgetClass;
private:
UPROPERTY(Transient)
TObjectPtr<UUserWidget> OptionsWidgetInstance = nullptr;
UPROPERTY(Transient)
TObjectPtr<class UParcelFriendListWidget> FriendListWidgetInstance = nullptr;
};
#include "UI/ParcelLobbyHUDWidget.h"
#include "Components/Button.h"
#include "Components/TextBlock.h"
#include "Components/ScrollBox.h"
#include "Components/EditableText.h"
#include "Components/Image.h"
#include "Components/CanvasPanel.h"
#include "GameFramework/PlayerController.h"
#include "Kismet/GameplayStatics.h"
#include "UI/ParcelLobbyPlayerSlotWidget.h"
#include "GameFramework/PlayerState.h"
#include "GameFramework/GameStateBase.h"
#include "Core/ParcelPlayerController.h"
#include "Core/ParcelGameState.h"
#include "Components/TextBlock.h"
#include "Engine/Texture2D.h"
#include "UI/ParcelFriendListWidget.h"
#include "Core/SessionSubsystem.h"
void UParcelLobbyHUDWidget::NativeConstruct()
{
Super::NativeConstruct();
// 1. 캔버스 및 초기 메뉴 상태 숨김 가드 세팅
if (Canvas_MenuContainer)
{
Canvas_MenuContainer->SetVisibility(ESlateVisibility::Collapsed);
}
if (Txt_EscPrompt)
{
Txt_EscPrompt->SetVisibility(ESlateVisibility::HitTestInvisible);
}
bIsMenuOpen = false;
// 2. 초기 로비 인풋 모드 설정 (마우스 커서 숨기기)
APlayerController* PC = GetOwningPlayer();
if (PC)
{
FInputModeGameOnly InputMode;
PC->SetInputMode(InputMode);
PC->bShowMouseCursor = false;
}
// 3. 로비 레이아웃 (방장/손님 권한별 버튼 가시성) 초기 1회 설정
SetupLobbyLayout();
// 4. 조작용 단추 클릭 이벤트 동적 연결
if (Btn_Options) Btn_Options->OnClicked.AddDynamic(this, &UParcelLobbyHUDWidget::HandleOptionsClicked);
if (Btn_Friends) Btn_Friends->OnClicked.AddDynamic(this, &UParcelLobbyHUDWidget::HandleFriendsClicked);
if (Btn_Leave) Btn_Leave->OnClicked.AddDynamic(this, &UParcelLobbyHUDWidget::HandleLeaveLobbyClicked);
if (Btn_SelectMap) Btn_SelectMap->OnClicked.AddDynamic(this, &UParcelLobbyHUDWidget::HandleSelectMapClicked);
if (Btn_Action) Btn_Action->OnClicked.AddDynamic(this, &UParcelLobbyHUDWidget::HandleActionOrStartClicked);
// 5. 엔터키 감지 채팅 입력기 초기화
if (EditableText_ChatInput)
{
EditableText_ChatInput->OnTextCommitted.AddDynamic(this, &UParcelLobbyHUDWidget::HandleChatTextCommitted);
EditableText_ChatInput->SetVisibility(ESlateVisibility::Collapsed);
}
// 6. 플레이어 컨트롤러에 내 HUD 위젯 주소 명함 건네기 (채팅 배달용)
if (AParcelPlayerController* ParcelPC = Cast<AParcelPlayerController>(GetOwningPlayer()))
{
ParcelPC->LobbyHUDWidgetInstance = this;
UE_LOG(LogTemp, Log, TEXT("[Lobby HUD] 플레이어 컨트롤러 상에 HUD 인스턴스 주소 등록 완료."));
}
// 7. GameState를 파싱하여 멀티플레이어 맵 레이턴시 동기화 랜선 직결
if (GetWorld())
{
if (AParcelGameState* ParcelGS = GetWorld()->GetGameState<AParcelGameState>())
{
ParcelGS->OnLobbyMapChanged.AddDynamic(this, &UParcelLobbyHUDWidget::HandleOnLobbyMapChanged);
// 클라이언트를 위해 현재 서버 공인 맵 인덱스로 화면 동기화
HandleOnLobbyMapChanged(ParcelGS->GetSelectedMapIndex());
}
}
// 8. Destroy 세션 서브시스템 연결
if (UGameInstance* GI = GetGameInstance())
{
if (USessionSubsystem* SessionSubsystem = GI->GetSubsystem<USessionSubsystem>())
{
SessionSubsystem->OnSessionDestroyComplete.AddDynamic(this, &UParcelLobbyHUDWidget::HandleOnSessionDestroyComplete);
}
}
SetIsFocusable(true);
}
FReply UParcelLobbyHUDWidget::NativeOnKeyDown(const FGeometry& MyGeometry, const FKeyEvent& InKeyEvent)
{
FKey PressedKey = InKeyEvent.GetKey();
if (PressedKey == EKeys::Escape)
{
UE_LOG(LogTemp, Log, TEXT("[Lobby HUD] ESC 키 감지. 현재 메뉴 상태: %s"), bIsMenuOpen ? TEXT("열림") : TEXT("닫힘"));
SetMenuVisibleState(!bIsMenuOpen);
return FReply::Handled();
}
if (PressedKey == EKeys::Enter)
{
if (!bIsMenuOpen && EditableText_ChatInput)
{
bool bIsChattingNow = (EditableText_ChatInput->GetVisibility() == ESlateVisibility::Visible);
SetChatInputInputMode(!bIsChattingNow);
return FReply::Handled();
}
}
return Super::NativeOnKeyDown(MyGeometry, InKeyEvent);
}
void UParcelLobbyHUDWidget::SetupLobbyLayout()
{
APlayerController* PC = GetOwningPlayer();
if (PC && PC->HasAuthority())
{
if (Btn_SelectMap) Btn_SelectMap->SetVisibility(ESlateVisibility::Visible);
if (Txt_ActionPrompt) Txt_ActionPrompt->SetText(FText::FromString(TEXT("게임 시작 (Host)")));
}
else
{
if (Btn_SelectMap) Btn_SelectMap->SetVisibility(ESlateVisibility::Collapsed);
if (Txt_ActionPrompt) Txt_ActionPrompt->SetText(FText::FromString(TEXT("준비 완료 (Client)")));
}
if (Txt_PlayerCount) Txt_PlayerCount->SetText(FText::FromString(TEXT("현재 인원: 1 / 4")));
if (Txt_MapName) Txt_MapName->SetText(FText::FromString(TEXT("컨베이어 창고 스테이지 01")));
}
void UParcelLobbyHUDWidget::SetMenuVisibleState(bool bNewState)
{
bIsMenuOpen = bNewState;
APlayerController* PC = GetOwningPlayer();
K2_OnMenuStateChanged(bIsMenuOpen);
if (bIsMenuOpen)
{
if (PC)
{
FInputModeUIOnly InputMode;
InputMode.SetWidgetToFocus(TakeWidget());
PC->SetInputMode(InputMode);
PC->bShowMouseCursor = true;
}
UE_LOG(LogTemp, Warning, TEXT("[Lobby HUD] 메뉴판 오픈 가동: UI 포커스 구속 및 커서 활성화."));
}
else
{
if (PC)
{
FInputModeGameOnly InputMode;
PC->SetInputMode(InputMode);
PC->bShowMouseCursor = false;
FSlateApplication::Get().SetAllUserFocusToGameViewport();
}
UE_LOG(LogTemp, Log, TEXT("[Lobby HUD] 메뉴판 폐쇄 가동: 시점 자유 조작 복구 복원 완료."));
}
}
void UParcelLobbyHUDWidget::SetChatInputInputMode(bool bFocusChat)
{
APlayerController* PC = GetOwningPlayer();
if (!PC || !EditableText_ChatInput) return;
if (bFocusChat)
{
EditableText_ChatInput->SetVisibility(ESlateVisibility::Visible);
FInputModeUIOnly ChatInputMode;
ChatInputMode.SetWidgetToFocus(EditableText_ChatInput->TakeWidget());
PC->SetInputMode(ChatInputMode);
}
else
{
EditableText_ChatInput->SetVisibility(ESlateVisibility::Collapsed);
FInputModeGameOnly GameInputMode;
PC->SetInputMode(GameInputMode);
FSlateApplication::Get().SetAllUserFocusToGameViewport();
}
}
void UParcelLobbyHUDWidget::HandleChatTextCommitted(const FText& Text, ETextCommit::Type CommitMethod)
{
if (!Text.IsEmpty() && CommitMethod == ETextCommit::OnEnter)
{
if (AParcelPlayerController* ParcelPC = Cast<AParcelPlayerController>(GetOwningPlayer()))
{
ParcelPC->Server_SendLobbyChatMessage(Text);
}
if (EditableText_ChatInput) EditableText_ChatInput->SetText(FText::GetEmpty());
}
SetChatInputInputMode(false);
}
void UParcelLobbyHUDWidget::AddChatLog(const FString& SenderName, const FText& Message)
{
if (!ScrollBox_ChatLogs) return;
UTextBlock* NewLogBlock = NewObject<UTextBlock>(this);
if (NewLogBlock)
{
FString FormattedString = FString::Printf(TEXT("[%s] : %s"), *SenderName, *Message.ToString());
NewLogBlock->SetText(FText::FromString(FormattedString));
NewLogBlock->SetAutoWrapText(true);
ScrollBox_ChatLogs->AddChild(NewLogBlock);
ScrollBox_ChatLogs->ScrollToEnd();
}
}
void UParcelLobbyHUDWidget::HandleOptionsClicked()
{
if (OptionsWidgetInstance && OptionsWidgetInstance->IsInViewport())
{
OptionsWidgetInstance->RemoveFromParent();
OptionsWidgetInstance = nullptr;
UE_LOG(LogTemp, Log, TEXT("[Lobby HUD] 옵션 위젯 토글 닫기 완료."));
return;
}
if (OptionsWidgetClass)
{
OptionsWidgetInstance = CreateWidget<UUserWidget>(GetOwningPlayer(), OptionsWidgetClass);
if (OptionsWidgetInstance)
{
OptionsWidgetInstance->AddToViewport(500);
UE_LOG(LogTemp, Log, TEXT("[Lobby HUD] WBP_Options 옵션 인터페이스 파이프라인 개방 개시"));
}
}
}
void UParcelLobbyHUDWidget::HandleFriendsClicked()
{
if (FriendListWidgetInstance && FriendListWidgetInstance->IsInViewport())
{
FriendListWidgetInstance->CloseFriendList();
FriendListWidgetInstance = nullptr;
UE_LOG(LogTemp, Log, TEXT("[Lobby HUD] 친구 목록 위젯 토글 닫기 완료."));
return;
}
if (FriendListWidgetClass)
{
FriendListWidgetInstance = CreateWidget<UParcelFriendListWidget>(GetOwningPlayer(), FriendListWidgetClass);
if (FriendListWidgetInstance)
{
FriendListWidgetInstance->AddToViewport(501);
UE_LOG(LogTemp, Log, TEXT("[Lobby HUD] UParcelFriendListWidget 스팀 친구 인터랙션 창구 오픈"));
}
}
}
void UParcelLobbyHUDWidget::HandleLeaveLobbyClicked()
{
UE_LOG(LogTemp, Warning, TEXT("[Lobby HUD] 로비 탈출 명령 감지. 안전 세션 철거 시퀀스 개시."));
if (UGameInstance* GI = GetGameInstance())
{
if (USessionSubsystem* SessionSubsystem = GI->GetSubsystem<USessionSubsystem>())
{
SessionSubsystem->DestroySession();
if (Btn_Leave) Btn_Leave->SetIsEnabled(false);
UE_LOG(LogTemp, Log, TEXT("[Lobby HUD] OSS 서브시스템에 세션 파괴 요청 송신 완료 -> 비동기 응답 대기 중..."));
return;
}
}
UGameplayStatics::OpenLevel(GetWorld(), TEXT("MainMenuLevel"), true);
}
void UParcelLobbyHUDWidget::HandleOnSessionDestroyComplete(bool bWasSuccessful)
{
UE_LOG(LogTemp, Log, TEXT("[Lobby HUD] OSS 세션 철거 완료 보고 수신 (성공 여부: %s) -> 메인 화면으로 전원 송환 처리!"),
bWasSuccessful ? TEXT("TRUE") : TEXT("FALSE"));
UWorld* World = GetWorld();
if (World)
{
UGameplayStatics::OpenLevel(World, TEXT("MainMenuLevel"), true);
}
}
void UParcelLobbyHUDWidget::HandleSelectMapClicked()
{
APlayerController* PC = GetOwningPlayer();
if (!PC || !PC->HasAuthority() || !MapDataTable) return;
TArray<FParcelMapStageData*> AllMapRows;
MapDataTable->GetAllRows<FParcelMapStageData>(TEXT("MapParsingContext"), AllMapRows);
if (AllMapRows.IsEmpty()) return;
int32 CurrentSyncedIndex = 0;
if (AParcelGameState* ParcelGS = GetWorld() ? GetWorld()->GetGameState<AParcelGameState>() : nullptr)
{
CurrentSyncedIndex = ParcelGS->GetSelectedMapIndex();
}
LocalCurrentMapIndex = (CurrentSyncedIndex + 1) % AllMapRows.Num();
if (AParcelPlayerController* ParcelPC = Cast<AParcelPlayerController>(PC))
{
ParcelPC->Server_RequestChangeLobbyMap(LocalCurrentMapIndex);
UE_LOG(LogTemp, Log, TEXT("[Lobby HUD] 방장 조작 센서 터짐 -> 서버에 %d번 맵 인덱스 동기화 요청"), LocalCurrentMapIndex);
}
}
void UParcelLobbyHUDWidget::HandleActionOrStartClicked()
{
APlayerController* PC = GetOwningPlayer();
if (PC && PC->HasAuthority())
{
UE_LOG(LogTemp, Log, TEXT("[Lobby HUD] 방장 전용: 리슨서버 전원 인게임 배달 구역(/Game/Maps/LV_DF_Stage01)으로 강제 트래블 개시"));
GetWorld()->ServerTravel(TEXT("/Game/Maps/LV_DF_Stage01?listen"));
}
else
{
bIsReady = !bIsReady;
if (Txt_ActionPrompt)
{
FString PromptStr = bIsReady ? TEXT("준비 취소") : TEXT("준비 완료 (Client)");
Txt_ActionPrompt->SetText(FText::FromString(PromptStr));
}
UE_LOG(LogTemp, Log, TEXT("[Lobby HUD] 클라이언트 준비 토글 변경: %s"), bIsReady ? TEXT("Ready") : TEXT("Not Ready"));
}
}
void UParcelLobbyHUDWidget::RefreshLobbyPlayers()
{
if (!ScrollBox_LobbyPlayers || !PlayerSlotClass) return;
ScrollBox_LobbyPlayers->ClearChildren();
AGameStateBase* GS = GetWorld() ? GetWorld()->GetGameState() : nullptr;
if (!GS)
{
UE_LOG(LogTemp, Warning, TEXT("[Lobby HUD] GameState가 아직 복제되지 않아 인원 리프레시를 보류합니다."));
return;
}
int32 CurrentCount = GS->PlayerArray.Num();
int32 MaxCount = 4;
if (Txt_PlayerCount)
{
FString CountStr = FString::Printf(TEXT("현재 인원: %d / %d"), CurrentCount, MaxCount);
Txt_PlayerCount->SetText(FText::FromString(CountStr));
}
for (int32 i = 0; i < GS->PlayerArray.Num(); ++i)
{
APlayerState* PS = GS->PlayerArray[i].Get();
if (!PS) continue;
UParcelLobbyPlayerSlotWidget* NewSlot = CreateWidget<UParcelLobbyPlayerSlotWidget>(GetOwningPlayer(), PlayerSlotClass);
if (NewSlot)
{
bool bIsHost = (i == 0) || (PS->GetOwningController() && PS->GetOwningController()->HasAuthority());
NewSlot->InitializeSlot(PS, bIsHost);
ScrollBox_LobbyPlayers->AddChild(NewSlot);
}
}
UE_LOG(LogTemp, Log, TEXT("[Lobby HUD] 서버 복제 배열 동기화 완료. 총 %d개의 플레이어 슬롯 재생성 완공."), CurrentCount);
}
void UParcelLobbyHUDWidget::HandleOnLobbyMapChanged(int32 NewMapIndex)
{
if (!MapDataTable) return;
TArray<FParcelMapStageData*> AllMapRows;
MapDataTable->GetAllRows<FParcelMapStageData>(TEXT("MapRenderingContext"), AllMapRows);
if (!AllMapRows.IsValidIndex(NewMapIndex))
{
UE_LOG(LogTemp, Error, TEXT("[Lobby HUD] 복제된 맵 인덱스(%d)가 데이터 테이블 범위를 벗어났습니다!"), NewMapIndex);
return;
}
FParcelMapStageData* SelectedStageData = AllMapRows[NewMapIndex];
if (!SelectedStageData) return;
if (Txt_MapName)
{
Txt_MapName->SetText(FText::FromString(SelectedStageData->StageName));
}
if (Img_MapThumbnail)
{
UTexture2D* LoadedThumbnail = SelectedStageData->StageThumbnail.LoadSynchronous();
if (LoadedThumbnail)
{
Img_MapThumbnail->SetBrushFromTexture(LoadedThumbnail);
}
}
LocalCurrentMapIndex = NewMapIndex;
UE_LOG(LogTemp, Log, TEXT("[Lobby HUD Synced] 전 클라이언트 화면에 %s 맵 비주얼 동기화 렌더링 완료"), *SelectedStageData->StageName);
}
2단계 : ParcelLobbyPlayerSlotWidget
#pragma once
#include "CoreMinimal.h"
#include "Blueprint/UserWidget.h"
#include "ParcelLobbyPlayerSlotWidget.generated.h"
class UTextBlock;
class UImage;
class APlayerState;
class UTexture2D;
/**
* UParcelLobbyPlayerSlotWidget
* 담당자 : JYW
*/
UCLASS(Abstract, Blueprintable)
class PARCEL_KNIGHT_API UParcelLobbyPlayerSlotWidget : public UUserWidget
{
GENERATED_BODY()
public:
void InitializeSlot(APlayerState* InPlayerState, bool bIsHost);
void UpdateReadyState(bool bInIsReady);
protected:
virtual void NativeConstruct() override;
virtual void NativeDestruct() override;
// UMG 위젯 바인딩
UPROPERTY(BlueprintReadOnly, meta = (BindWidget))
TObjectPtr<UTextBlock> Txt_LobbyPlayerName;
UPROPERTY(BlueprintReadOnly, meta = (BindWidget))
TObjectPtr<UTextBlock> Txt_LobbyPlayerStatus;
UPROPERTY(BlueprintReadOnly, meta = (BindWidget))
TObjectPtr<UImage> Img_LobbyPlayerAvatar;
// 블루프린트 연출용 이벤트
UFUNCTION(BlueprintImplementableEvent, Category = "LobbySlot")
void K2_OnSlotInitialized(bool bIsHost);
private:
void TryLoadSteamAvatar();
UPROPERTY(Transient)
TObjectPtr<APlayerState> CachedPlayerState = nullptr;
FTimerHandle AvatarRetryTimerHandle;
int32 AvatarRetryCount = 0;
static constexpr int32 MaxAvatarRetryCount = 10;
};
#include "UI/ParcelLobbyPlayerSlotWidget.h"
#include "Components/TextBlock.h"
#include "Components/Image.h"
#include "GameFramework/PlayerState.h"
#include "AdvancedSteamFriendsLibrary.h"
#include "Engine/Texture2D.h"
#include "TimerManager.h"
void UParcelLobbyPlayerSlotWidget::InitializeSlot(APlayerState* InPlayerState, bool bIsHost)
{
if (!InPlayerState) return;
CachedPlayerState = InPlayerState;
if (Txt_LobbyPlayerName)
{
Txt_LobbyPlayerName->SetText(FText::FromString(InPlayerState->GetPlayerName()));
}
if (Txt_LobbyPlayerStatus)
{
FString ClearStatus = bIsHost ? TEXT("방장 (Host)") : TEXT("대기 중...");
Txt_LobbyPlayerStatus->SetText(FText::FromString(ClearStatus));
}
TryLoadSteamAvatar();
K2_OnSlotInitialized(bIsHost);
}
void UParcelLobbyPlayerSlotWidget::UpdateReadyState(bool bInIsReady)
{
if (Txt_LobbyPlayerStatus)
{
FString StatusStr = bInIsReady ? TEXT("준비 완료!") : TEXT("대기 중...");
Txt_LobbyPlayerStatus->SetText(FText::FromString(StatusStr));
}
}
void UParcelLobbyPlayerSlotWidget::NativeConstruct()
{
Super::NativeConstruct();
}
void UParcelLobbyPlayerSlotWidget::NativeDestruct()
{
if (GetWorld())
{
GetWorld()->GetTimerManager().ClearTimer(AvatarRetryTimerHandle);
}
Super::NativeDestruct();
}
void UParcelLobbyPlayerSlotWidget::TryLoadSteamAvatar()
{
if (!CachedPlayerState) return;
FBPUniqueNetId UniqueNetId;
UniqueNetId.UniqueNetId = CachedPlayerState->GetUniqueId().GetUniqueNetId();
if (!UniqueNetId.IsValid()) return;
EBlueprintAsyncResultSwitch Result = EBlueprintAsyncResultSwitch::OnFailure;
if (UTexture2D* LoadedAvatar = UAdvancedSteamFriendsLibrary::GetSteamFriendAvatar(
UniqueNetId, Result, SteamAvatarSize::SteamAvatar_Medium))
{
if (Img_LobbyPlayerAvatar)
{
Img_LobbyPlayerAvatar->SetBrushFromTexture(LoadedAvatar);
}
if (GetWorld())
{
GetWorld()->GetTimerManager().ClearTimer(AvatarRetryTimerHandle);
}
return;
}
if (Result == EBlueprintAsyncResultSwitch::AsyncLoading && AvatarRetryCount < MaxAvatarRetryCount)
{
UAdvancedSteamFriendsLibrary::RequestSteamFriendInfo(UniqueNetId, false);
++AvatarRetryCount;
if (GetWorld())
{
GetWorld()->GetTimerManager().SetTimer(
AvatarRetryTimerHandle, this, &UParcelLobbyPlayerSlotWidget::TryLoadSteamAvatar, 0.3f, false);
}
}
}
3단계 : ParcelPlayerController 수정
// 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;
class UParcelLobbyHUDWidget;
/**
* 담당자: 김로운
*/
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 AcknowledgePossession(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;
public:
/** [Client -> Server] 클라이언트가 입력한 채팅을 서버 방장에게 전달하는 Reliable RPC */
UFUNCTION(Server, Reliable, WithValidation)
void Server_SendLobbyChatMessage(const FText& ChatText);
/** [Server -> Client] 서버가 모든 접속자의 로컬 HUD에 채팅방 글을 꽂아주는 브로드캐스트 RPC */
UFUNCTION(Client, Reliable)
void Client_ReceiveLobbyChatMessage(const FString& SenderName, const FText& ChatText);
/** 로비 HUD 위젯 인스턴스 주소를 플레이어 컨트롤러가 안전하게 쥐고 있을 주머니 변수 */
UPROPERTY(Transient, BlueprintReadOnly, Category = "ParcelUI")
TObjectPtr<UParcelLobbyHUDWidget> LobbyHUDWidgetInstance;
public:
UFUNCTION(Server, Reliable, WithValidation)
void Server_RequestChangeLobbyMap(int32 NewMapIndex);
};
#include "Core/ParcelPlayerController.h"
#include "ParcelLog.h"
#include "UI/ParcelInGameDeadHUDWidget.h"
#include "UI/ParcelInGameESCMenuWidget.h"
#include "UI/ParcelHUDWidget.h"
#include "Core/ParcelCheatManager.h"
#include "Blueprint/UserWidget.h"
#include "GameFramework/PlayerState.h"
#include "UI/ParcelLobbyHUDWidget.h"
#include "Core/ParcelGameState.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::AcknowledgePossession(APawn* InPawn)
{
Super::AcknowledgePossession(InPawn);
if (IsLocalController())
{
CONTROLLER_LOG(Log, TEXT("[클라이언트 빙의 확정] AcknowledgePossession 감지 - UI 최종 정렬을 실행합니다."));
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);
// Respawn 후 새로운 컴포넌트들과 동기화 및 델리게이트
if (UParcelHUDWidget* ParcelHUD = Cast<UParcelHUDWidget>(HUDWidgetInstance))
{
ParcelHUD->RequestRebindPlayerEvents();
}
}
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();
}
}
}
bool AParcelPlayerController::Server_SendLobbyChatMessage_Validate(const FText& ChatText)
{
return !ChatText.IsEmpty() && ChatText.ToString().Len() < 200;
}
void AParcelPlayerController::Server_SendLobbyChatMessage_Implementation(const FText& ChatText)
{
if (!GetWorld()) return;
FString SenderNickname = PlayerState ? PlayerState->GetPlayerName() : TEXT("알 수 없는 참가자");
for (FConstPlayerControllerIterator It = GetWorld()->GetPlayerControllerIterator(); It; ++It)
{
AParcelPlayerController* TargetPC = Cast<AParcelPlayerController>(It->Get());
if (TargetPC)
{
TargetPC->Client_ReceiveLobbyChatMessage(SenderNickname, ChatText);
}
}
}
void AParcelPlayerController::Client_ReceiveLobbyChatMessage_Implementation(const FString& SenderName, const FText& ChatText)
{
if (LobbyHUDWidgetInstance && LobbyHUDWidgetInstance->IsValidLowLevel())
{
LobbyHUDWidgetInstance->AddChatLog(SenderName, ChatText);
}
UE_LOG(LogTemp, Log, TEXT("[Lobby Chat RPC] %s 님의 메시지 수신 완료: %s"), *SenderName, *ChatText.ToString());
}
bool AParcelPlayerController::Server_RequestChangeLobbyMap_Validate(int32 NewMapIndex)
{
return IsLocalController();
}
void AParcelPlayerController::Server_RequestChangeLobbyMap_Implementation(int32 NewMapIndex)
{
if (AParcelGameState* ParcelGS = GetWorld() ? GetWorld()->GetGameState<AParcelGameState>() : nullptr)
{
ParcelGS->SetSelectedMapIndex(NewMapIndex);
UE_LOG(LogTemp, Warning, TEXT("[Server PC] 방장 권한 확인 완료. 월드 맵 인덱스를 %d번으로 강제 변조합니다."), NewMapIndex);
}
}
4단계 : ParcelGameState 수정
// Fill out your copyright notice in the Description page of Project Settings.
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/GameState.h"
#include "ParcelGameState.generated.h"
class UTeamScoreComponent;
class UShopComponent;
DECLARE_DYNAMIC_MULTICAST_DELEGATE_ThreeParams(FOnDeliveryLogReceivedSignature, const FString&, PlayerName, const FString&, BoxName, bool, bSuccess);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnLobbyMapChangedSignature, int32, NewMapIndex);
/**
* 팀 점수 및 남은 시간을 관리하는 GameState
* 실제 로직은 UTeamScoreComponent가 담당한다.
*
* 담당자: 한수현
*/
UCLASS()
class PARCEL_KNIGHT_API AParcelGameState : public AGameState
{
GENERATED_BODY()
public:
AParcelGameState();
UTeamScoreComponent* GetTeamScoreComponent() const;
UShopComponent* GetShopComponent() const;
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
// [UI]
UPROPERTY(BlueprintAssignable, Category = "ParcelUI|Events")
FOnDeliveryLogReceivedSignature OnDeliveryLogReceived;
UFUNCTION(NetMulticast, Reliable)
void Multicast_NotifyDeliveryLog(const FString& PlayerName, const FString& BoxName, bool bSuccess);
private:
UPROPERTY(VisibleAnywhere, Category = "Components")
TObjectPtr<UTeamScoreComponent> TeamScoreComp;
UPROPERTY(VisibleAnywhere, Category = "Components")
TObjectPtr<UShopComponent> ShopComp;
public:
UPROPERTY(BlueprintAssignable, Category = "ParcelUI|Events")
FOnLobbyMapChangedSignature OnLobbyMapChanged;
void SetSelectedMapIndex(int32 NewIndex);
UFUNCTION(BlueprintPure, Category = "ParcelLobby")
FORCEINLINE int32 GetSelectedMapIndex() const { return SelectedMapIndex; }
protected:
UFUNCTION()
void OnRep_SelectedMapIndex();
private:
UPROPERTY(ReplicatedUsing = OnRep_SelectedMapIndex, BlueprintReadOnly, Category = "ParcelLobby", meta = (AllowPrivateAccess = "true"))
int32 SelectedMapIndex = 0;
};
// Fill out your copyright notice in the Description page of Project Settings.
#include "Core/ParcelGameState.h"
#include "Core/TeamScoreComponent.h"
#include "Core/ShopComponent.h"
#include "Net/UnrealNetwork.h"
AParcelGameState::AParcelGameState()
{
TeamScoreComp = CreateDefaultSubobject<UTeamScoreComponent>("TeamScoreComponent");
ShopComp = CreateDefaultSubobject<UShopComponent>("ShopComponent");
// [UI] 클라이언트에 의해 복제 허용
TeamScoreComp->SetIsReplicated(true);
SelectedMapIndex = 0;
}
void AParcelGameState::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(AParcelGameState, SelectedMapIndex);
}
UTeamScoreComponent* AParcelGameState::GetTeamScoreComponent() const
{
return TeamScoreComp;
}
UShopComponent* AParcelGameState::GetShopComponent() const
{
return ShopComp;
}
// [UI]
void AParcelGameState::Multicast_NotifyDeliveryLog_Implementation(const FString& PlayerName, const FString& BoxName, bool bSuccess)
{
if (OnDeliveryLogReceived.IsBound())
{
OnDeliveryLogReceived.Broadcast(PlayerName, BoxName, bSuccess);
}
}
void AParcelGameState::SetSelectedMapIndex(int32 NewIndex)
{
if (!HasAuthority()) return;
if (SelectedMapIndex != NewIndex)
{
SelectedMapIndex = NewIndex;
if (OnLobbyMapChanged.IsBound())
{
OnLobbyMapChanged.Broadcast(SelectedMapIndex);
}
}
}
void AParcelGameState::OnRep_SelectedMapIndex()
{
if (OnLobbyMapChanged.IsBound())
{
OnLobbyMapChanged.Broadcast(SelectedMapIndex);
}
UE_LOG(LogTemp, Log, TEXT("[GameState Synced] 서버로부터 신규 맵 인덱스(%d) 패킷 동동기화 완료 -> HUD 브로드캐스트 전송"), SelectedMapIndex);
}
5단계 : DT_LobbyMapData 생성

6단계 :SessionSubsystem 수정
// Fill out your copyright notice in the Description page of Project Settings.
#pragma once
#include "CoreMinimal.h"
#include "Interfaces/OnlineSessionInterface.h"
#include "OnlineSessionSettings.h"
#include "BlueprintDataDefinitions.h"
#include "Subsystems/GameInstanceSubsystem.h"
#include "SessionSubsystem.generated.h"
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnSessionCreateComplete, bool, bWasSuccessful);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnSessionFindComplete, bool, bWasSuccessful);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnSessionJoinComplete, bool, bWasSuccessful);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnSessionDestroyComplete, bool, bWasSuccessful);
/**
* 리슨 서버 세션 생성·참가·종료를 담당하는 서브시스템
* GameInstance에 귀속되며 씬 전환 후에도 세션 상태를 유지한다.
* OnlineSubsystem 백엔드(Null / EOS / Steam)를 추상화하여
* ini 설정 변경만으로 교체 가능하다.
*
* 담당자: 한수현
*/
UCLASS(BlueprintType)
class PARCEL_KNIGHT_API USessionSubsystem : public UGameInstanceSubsystem
{
GENERATED_BODY()
public:
UPROPERTY(BlueprintAssignable)
FOnSessionCreateComplete OnSessionCreateComplete;
UPROPERTY(BlueprintAssignable)
FOnSessionFindComplete OnSessionFindComplete;
UPROPERTY(BlueprintAssignable)
FOnSessionJoinComplete OnSessionJoinComplete;
UPROPERTY(BlueprintAssignable)
FOnSessionDestroyComplete OnSessionDestroyComplete;
UFUNCTION(BlueprintCallable)
void CreateSession(int32 NumPublicConnections);
UFUNCTION(BlueprintCallable)
void FindSessions();
// Blueprint에서 FOnlineSessionSearchResult를 직접 쓸 수 없으므로 인덱스로 참가
UFUNCTION(BlueprintCallable)
void JoinSession(int32 SessionIndex);
// Steam 초대 수락으로 전달된 검색 결과를 기존 JoinSession 완료/이동 흐름에 연결합니다.
bool JoinSessionResult(const FOnlineSessionSearchResult& SessionResult);
UFUNCTION(BlueprintCallable)
void DestroySession();
// TODO: 로비맵 완성 후 OnCreateSessionComplete의 이동 대상을 로비맵으로 교체
UFUNCTION(BlueprintCallable)
void StartGame(const FString& MapPath);
UFUNCTION(BlueprintCallable, BlueprintPure)
int32 GetSearchResultCount() const;
UFUNCTION(BlueprintCallable, BlueprintPure)
FString GetSessionOwnerName(int32 Index) const;
UFUNCTION(BlueprintCallable, BlueprintPure)
int32 GetSessionPlayerCount(int32 Index) const;
UFUNCTION(BlueprintCallable, BlueprintPure)
bool CanInviteToCurrentSession() const;
bool SendSessionInviteToFriend(APlayerController* PlayerController, const FBPUniqueNetId& FriendUniqueNetId) const;
private:
enum class ESessionOperation : uint8 { None, Creating, Finding, Joining };
bool StartJoinSession(const FOnlineSessionSearchResult& SessionResult);
void OnCreateSessionComplete(FName SessionName, bool bWasSuccessful);
void OnStartSessionComplete(FName SessionName, bool bWasSuccessful);
void OnFindSessionsComplete(bool bWasSuccessful);
void OnJoinSessionComplete(FName SessionName, EOnJoinSessionCompleteResult::Type Result);
void OnDestroySessionComplete(FName SessionName, bool bWasSuccessful);
// 진행 중인 작업을 취소하고 실패 브로드캐스트
void CancelCurrentOperation();
// 타임아웃 발동 시 호출
void OnOperationTimeout();
// 타임아웃 타이머를 취소하고 진행 상태를 초기화
void ClearOperationState();
// 세션 파괴 완료 후 자동 재생성을 위한 플래그
bool bPendingCreate = false;
int32 PendingNumConnections = 0;
bool bIsOperationInProgress = false;
ESessionOperation CurrentOperation = ESessionOperation::None;
// 응답 없을 때 강제 리셋까지 대기 시간 (초)
static constexpr float OperationTimeoutSeconds = 30.f;
FTimerHandle OperationTimeoutHandle;
TSharedPtr<FOnlineSessionSearch> SessionSearch;
FDelegateHandle CreateSessionHandle;
FDelegateHandle StartSessionHandle;
FDelegateHandle FindSessionsHandle;
FDelegateHandle JoinSessionHandle;
FDelegateHandle DestroySessionHandle;
};
// Fill out your copyright notice in the Description page of Project Settings.
#include "Core/SessionSubsystem.h"
#include "Core/ParcelGameInstance.h"
#include "AdvancedFriendsLibrary.h"
#include "GameFramework/PlayerController.h"
#include "Interfaces/OnlineIdentityInterface.h"
#include "OnlineSubsystemUtils.h"
#include "Online/OnlineSessionNames.h"
void USessionSubsystem::ClearOperationState()
{
if (UWorld* World = GetWorld())
World->GetTimerManager().ClearTimer(OperationTimeoutHandle);
bIsOperationInProgress = false;
CurrentOperation = ESessionOperation::None;
}
void USessionSubsystem::CancelCurrentOperation()
{
// 등록된 OSS 델리게이트 먼저 해제 — 취소 후 구 콜백이 재발동되지 않도록
if (IOnlineSubsystem* OSS = IOnlineSubsystem::Get())
{
if (IOnlineSessionPtr Sessions = OSS->GetSessionInterface())
{
switch (CurrentOperation)
{
case ESessionOperation::Creating: Sessions->ClearOnCreateSessionCompleteDelegate_Handle(CreateSessionHandle); break;
case ESessionOperation::Finding: Sessions->ClearOnFindSessionsCompleteDelegate_Handle(FindSessionsHandle); break;
case ESessionOperation::Joining: Sessions->ClearOnJoinSessionCompleteDelegate_Handle(JoinSessionHandle);
JoinSessionHandle.Reset();
break;
default: break;
}
}
}
const ESessionOperation Op = CurrentOperation;
ClearOperationState();
switch (Op)
{
case ESessionOperation::Creating: OnSessionCreateComplete.Broadcast(false); break;
case ESessionOperation::Finding: OnSessionFindComplete.Broadcast(false); break;
case ESessionOperation::Joining: OnSessionJoinComplete.Broadcast(false); break;
default: break;
}
}
void USessionSubsystem::OnOperationTimeout()
{
if (!bIsOperationInProgress) return;
CancelCurrentOperation();
}
void USessionSubsystem::CreateSession(int32 NumPublicConnections)
{
// 다른 작업 중이면 취소; 이미 Creating 중인 경우(Destroy→Create 내부 재진입)는 그대로 유지
if (bIsOperationInProgress && CurrentOperation != ESessionOperation::Creating)
CancelCurrentOperation();
// Creating 상태로 진입 (재진입 시 타이머 재시작)
if (UWorld* World = GetWorld())
World->GetTimerManager().SetTimer(OperationTimeoutHandle, this, &USessionSubsystem::OnOperationTimeout, OperationTimeoutSeconds, false);
bIsOperationInProgress = true;
CurrentOperation = ESessionOperation::Creating;
IOnlineSubsystem* OSS = IOnlineSubsystem::Get();
if (!OSS) { CancelCurrentOperation(); return; }
IOnlineSessionPtr Sessions = OSS->GetSessionInterface();
if (!Sessions.IsValid()) { CancelCurrentOperation(); return; }
// 동일 이름 세션이 이미 있으면 파괴 후 자동 재생성 (OnDestroySessionComplete에서 이어받음)
if (Sessions->GetNamedSession(NAME_GameSession))
{
bPendingCreate = true;
PendingNumConnections = NumPublicConnections;
DestroySession();
return;
}
FOnlineSessionSettings SessionSettings;
// Null OSS이면 LAN 모드, EOS·Steam이면 온라인 모드 — ini 변경만으로 전환 가능
SessionSettings.bIsLANMatch = OSS->GetSubsystemName() == "NULL";
SessionSettings.NumPublicConnections = NumPublicConnections;
SessionSettings.bShouldAdvertise = true;
SessionSettings.bAllowInvites = true;
SessionSettings.bUsesPresence = true;
SessionSettings.bUseLobbiesIfAvailable = OSS->GetSubsystemName() == FName(TEXT("STEAM"));
SessionSettings.bAllowJoinInProgress = true;
CreateSessionHandle = Sessions->AddOnCreateSessionCompleteDelegate_Handle(
FOnCreateSessionCompleteDelegate::CreateUObject(this, &USessionSubsystem::OnCreateSessionComplete)
);
Sessions->CreateSession(0, NAME_GameSession, SessionSettings);
}
void USessionSubsystem::FindSessions()
{
if (bIsOperationInProgress)
CancelCurrentOperation();
if (UWorld* World = GetWorld())
World->GetTimerManager().SetTimer(OperationTimeoutHandle, this, &USessionSubsystem::OnOperationTimeout, OperationTimeoutSeconds, false);
bIsOperationInProgress = true;
CurrentOperation = ESessionOperation::Finding;
IOnlineSubsystem* OSS = IOnlineSubsystem::Get();
if (!OSS) { CancelCurrentOperation(); return; }
IOnlineSessionPtr Sessions = OSS->GetSessionInterface();
if (!Sessions.IsValid()) { CancelCurrentOperation(); return; }
SessionSearch = MakeShared<FOnlineSessionSearch>();
SessionSearch->MaxSearchResults = 5000;
SessionSearch->bIsLanQuery = OSS->GetSubsystemName() == "NULL";
SessionSearch->TimeoutInSeconds = 10.0f;
SessionSearch->QuerySettings.Set(SEARCH_LOBBIES, true, EOnlineComparisonOp::Equals);
FindSessionsHandle = Sessions->AddOnFindSessionsCompleteDelegate_Handle(
FOnFindSessionsCompleteDelegate::CreateUObject(this, &USessionSubsystem::OnFindSessionsComplete)
);
Sessions->FindSessions(0, SessionSearch.ToSharedRef());
}
void USessionSubsystem::JoinSession(int32 SessionIndex)
{
if (!SessionSearch.IsValid() || !SessionSearch->SearchResults.IsValidIndex(SessionIndex))
return;
StartJoinSession(SessionSearch->SearchResults[SessionIndex]);
}
bool USessionSubsystem::JoinSessionResult(const FOnlineSessionSearchResult& SessionResult)
{
return StartJoinSession(SessionResult);
}
bool USessionSubsystem::StartJoinSession(const FOnlineSessionSearchResult& SessionResult)
{
if (!SessionResult.IsValid()) { OnSessionJoinComplete.Broadcast(false); return false; }
if (bIsOperationInProgress)
CancelCurrentOperation();
if (UWorld* World = GetWorld())
World->GetTimerManager().SetTimer(OperationTimeoutHandle, this, &USessionSubsystem::OnOperationTimeout, OperationTimeoutSeconds, false);
bIsOperationInProgress = true;
CurrentOperation = ESessionOperation::Joining;
IOnlineSubsystem* OSS = IOnlineSubsystem::Get();
if (!OSS) { CancelCurrentOperation(); return false; }
IOnlineSessionPtr Sessions = OSS->GetSessionInterface();
if (!Sessions.IsValid()) { CancelCurrentOperation(); return false; }
if (JoinSessionHandle.IsValid())
{
Sessions->ClearOnJoinSessionCompleteDelegate_Handle(JoinSessionHandle);
JoinSessionHandle.Reset();
}
JoinSessionHandle = Sessions->AddOnJoinSessionCompleteDelegate_Handle(
FOnJoinSessionCompleteDelegate::CreateUObject(this, &USessionSubsystem::OnJoinSessionComplete)
);
if (!Sessions->JoinSession(0, NAME_GameSession, SessionResult))
{
Sessions->ClearOnJoinSessionCompleteDelegate_Handle(JoinSessionHandle);
JoinSessionHandle.Reset();
CancelCurrentOperation();
return false;
}
return true;
}
void USessionSubsystem::DestroySession()
{
IOnlineSubsystem* OSS = IOnlineSubsystem::Get();
if (!OSS) return;
IOnlineSessionPtr Sessions = OSS->GetSessionInterface();
if (!Sessions.IsValid()) return;
DestroySessionHandle = Sessions->AddOnDestroySessionCompleteDelegate_Handle(
FOnDestroySessionCompleteDelegate::CreateUObject(this, &USessionSubsystem::OnDestroySessionComplete)
);
Sessions->DestroySession(NAME_GameSession);
}
void USessionSubsystem::StartGame(const FString& MapPath)
{
GetWorld()->ServerTravel(MapPath + "?listen");
}
int32 USessionSubsystem::GetSearchResultCount() const
{
if (SessionSearch.IsValid())
return SessionSearch->SearchResults.Num();
return 0;
}
FString USessionSubsystem::GetSessionOwnerName(int32 Index) const
{
if (!SessionSearch.IsValid() || !SessionSearch->SearchResults.IsValidIndex(Index))
return TEXT("");
// OwningUserName: OSS가 기록한 세션 호스트의 플레이어 이름, Steam사용?
return SessionSearch->SearchResults[Index].Session.OwningUserName;
}
int32 USessionSubsystem::GetSessionPlayerCount(int32 Index) const
{
if (!SessionSearch.IsValid() || !SessionSearch->SearchResults.IsValidIndex(Index))
return 0;
const FOnlineSession& Session = SessionSearch->SearchResults[Index].Session;
// 최대 인원 - 남은 빈 슬롯 = 현재 접속 인원
return Session.SessionSettings.NumPublicConnections - Session.NumOpenPublicConnections;
}
bool USessionSubsystem::CanInviteToCurrentSession() const
{
IOnlineSubsystem* OSS = IOnlineSubsystem::Get();
if (!OSS || !GetWorld() || GetWorld()->GetNetMode() == NM_Client)
{
return false;
}
IOnlineSessionPtr Sessions = OSS->GetSessionInterface();
if (!Sessions.IsValid())
{
return false;
}
FNamedOnlineSession* NamedSession = Sessions->GetNamedSession(NAME_GameSession);
if (!NamedSession || !NamedSession->SessionSettings.bAllowInvites)
{
return false;
}
const EOnlineSessionState::Type SessionState = Sessions->GetSessionState(NAME_GameSession);
if (SessionState == EOnlineSessionState::NoSession || SessionState == EOnlineSessionState::Destroying)
{
return false;
}
IOnlineIdentityPtr Identity = OSS->GetIdentityInterface();
const TSharedPtr<const FUniqueNetId> LocalUserId = Identity.IsValid() ? Identity->GetUniquePlayerId(0) : nullptr;
if (LocalUserId.IsValid() && NamedSession->OwningUserId.IsValid())
{
return *LocalUserId == *NamedSession->OwningUserId;
}
// 일부 로컬/개발 OSS는 소유자 ID를 채우지 않으므로 listen host 여부를 안전한 fallback으로 사용합니다.
return GetWorld()->GetNetMode() == NM_ListenServer;
}
bool USessionSubsystem::SendSessionInviteToFriend(
APlayerController* PlayerController,
const FBPUniqueNetId& FriendUniqueNetId) const
{
if (!PlayerController || !PlayerController->IsLocalController() ||
!CanInviteToCurrentSession() || !FriendUniqueNetId.IsValid())
{
return false;
}
EBlueprintResultSwitch Result = EBlueprintResultSwitch::OnFailure;
UAdvancedFriendsLibrary::SendSessionInviteToFriend(PlayerController, FriendUniqueNetId, Result);
return Result == EBlueprintResultSwitch::OnSuccess;
}
//---------------세션 컴플리트----------------------------------------------------------
void USessionSubsystem::OnCreateSessionComplete(FName SessionName, bool bWasSuccessful)
{
IOnlineSubsystem* OSS = IOnlineSubsystem::Get();
if (!OSS) return;
IOnlineSessionPtr Sessions = OSS->GetSessionInterface();
if (!Sessions.IsValid()) return;
Sessions->ClearOnCreateSessionCompleteDelegate_Handle(CreateSessionHandle);
ClearOperationState();
OnSessionCreateComplete.Broadcast(bWasSuccessful);
if (bWasSuccessful && GetWorld()->GetNetMode() != NM_Client)
{
StartSessionHandle = Sessions->AddOnStartSessionCompleteDelegate_Handle(
FOnStartSessionCompleteDelegate::CreateUObject(this, &USessionSubsystem::OnStartSessionComplete)
);
Sessions->StartSession(NAME_GameSession);
}
}
void USessionSubsystem::OnStartSessionComplete(FName SessionName, bool bWasSuccessful)
{
IOnlineSubsystem* OSS = IOnlineSubsystem::Get();
if (!OSS) return;
IOnlineSessionPtr Sessions = OSS->GetSessionInterface();
if (!Sessions.IsValid()) return;
Sessions->ClearOnStartSessionCompleteDelegate_Handle(StartSessionHandle);
if (bWasSuccessful)
if (UParcelGameInstance* GI = Cast<UParcelGameInstance>(GetGameInstance()))
GetWorld()->ServerTravel(GI->GetPendingMapPath() + "?listen");
}
void USessionSubsystem::OnFindSessionsComplete(bool bWasSuccessful)
{
IOnlineSubsystem* OSS = IOnlineSubsystem::Get();
if (!OSS) return;
IOnlineSessionPtr Sessions = OSS->GetSessionInterface();
if (!Sessions.IsValid()) return;
Sessions->ClearOnFindSessionsCompleteDelegate_Handle(FindSessionsHandle);
ClearOperationState();
OnSessionFindComplete.Broadcast(bWasSuccessful);
}
void USessionSubsystem::OnJoinSessionComplete(FName SessionName, EOnJoinSessionCompleteResult::Type Result)
{
IOnlineSubsystem* OSS = IOnlineSubsystem::Get();
if (!OSS) return;
IOnlineSessionPtr Sessions = OSS->GetSessionInterface();
if (!Sessions.IsValid()) return;
Sessions->ClearOnJoinSessionCompleteDelegate_Handle(JoinSessionHandle);
JoinSessionHandle.Reset();
ClearOperationState();
OnSessionJoinComplete.Broadcast(Result == EOnJoinSessionCompleteResult::Success);
if (Result == EOnJoinSessionCompleteResult::Success)
{
FString TravelURL;
// OSS 내부 주소를 클라이언트가 접속 가능한 IP:Port 형태로 변환
if (Sessions->GetResolvedConnectString(NAME_GameSession, TravelURL))
{
APlayerController* PC = GetWorld()->GetFirstPlayerController();
if (PC) PC->ClientTravel(TravelURL, ETravelType::TRAVEL_Absolute);
}
}
}
void USessionSubsystem::OnDestroySessionComplete(FName SessionName, bool bWasSuccessful)
{
IOnlineSubsystem* OSS = IOnlineSubsystem::Get();
if (!OSS) return;
IOnlineSessionPtr Sessions = OSS->GetSessionInterface();
if (!Sessions.IsValid()) return;
Sessions->ClearOnDestroySessionCompleteDelegate_Handle(DestroySessionHandle);
// CreateSession 중 기존 세션 제거였으면 Broadcast 없이 바로 재생성
if (bPendingCreate)
{
bPendingCreate = false;
CreateSession(PendingNumConnections);
return;
}
OnSessionDestroyComplete.Broadcast(bWasSuccessful);
}
7단계 : WBP_LobbyHUD 만들기
1) 화면 구성

2) 이벤트 그래프

8단계 : WBP_LobbyPlayerSlot 만들기
1) 화면 구성

2) 이벤트 그래프
