Add Steam/LAN multiplayer menu with map and IP:Port connect, plus headless web admin.

Players can host or join via Steam or LAN, pick maps, and connect by address; dedicated servers expose an HTTP panel for port and game rules.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
pixachux 2026-07-22 19:06:28 +02:00
parent c0d9450d30
commit 932157eda5
16 changed files with 1417 additions and 43 deletions

View File

@ -242,9 +242,12 @@ bEnabled=true
bEnabled=true bEnabled=true
SteamDevAppId=480 SteamDevAppId=480
bInitServerOnClient=true bInitServerOnClient=true
bVACEnabled=0
[/Script/Engine.GameEngine] [/Script/Engine.GameEngine]
+NetDriverDefinitions=(DefName="GameNetDriver",DriverClassName="OnlineSubsystemUtils.IpNetDriver",DriverClassNameFallback="OnlineSubsystemUtils.IpNetDriver") +NetDriverDefinitions=(DefName="GameNetDriver",DriverClassName="OnlineSubsystemUtils.IpNetDriver",DriverClassNameFallback="OnlineSubsystemUtils.IpNetDriver")
; Steam sessions are selected in the multiplayer menu (Backend = Steam / Auto).
; When shipping on Steam, set DefaultPlatformService=Steam and SteamNetDriver above.
[/Script/OnlineSubsystemUtils.OnlineSession] [/Script/OnlineSubsystemUtils.OnlineSession]
bUseBuildIdOverride=false bUseBuildIdOverride=false

View File

@ -1,5 +1,10 @@
[/Script/VocationLife.VocationServerSettings] [/Script/VocationLife.VocationServerSettings]
ServerName=VocationLife Dedicated Server ServerName=VocationLife Dedicated Server
MaxPlayers=4 MaxPlayers=4
Port=7777 GamePort=7777
bLANOnly=true AdminHttpPort=8080
bLANOnly=False
bUseSteam=True
AdminPassword=vocation
DefaultMapPath=/Engine/Maps/Entry
GameRules=(bFriendlyFire=False,DayLengthMinutes=20.000000,bAllowVocationSwitch=True,StartingOre=0,MiningRespawnSeconds=30.000000)

63
Docs/Multiplayer.md Normal file
View File

@ -0,0 +1,63 @@
# Multiplayer, Steam & Server Admin
## In-Game Menu (`M`)
Opens automatically on start and again with **M**:
- **Map** selection
- **Backend**: Auto / Steam / LAN
- **Host Port** + Max Players
- **Find Steam** / **Find LAN**
- **Join selected session**
- **Connect by IP:Port**
- **Play Solo**
## Steam
1. Steam Client must be running.
2. Dev AppId is `480` (Spacewar) in `Config/DefaultEngine.ini` — replace with your own AppId for release.
3. In the menu choose Backend **Steam** or **Auto**.
4. Host on one PC, Find Steam on the other.
LAN/IP works without Steam (Backend **LAN** or Direct Connect).
## Direct Connect
```
IP: 192.168.x.x
Port: 7777
```
Or console: `open 192.168.x.x:7777`
## Dedicated Server Web Admin
On headless / dedicated start, admin UI binds to **AdminHttpPort** (default `8080`):
```
http://192.168.178.129:8080
```
Default password: `vocation` (change in `Config/ServerConfig.ini`).
You can change:
- Server name, game port, max players, map
- LAN / Steam preference
- Game rules (friendly fire, day length, mining respawn, …)
**Game port changes require a server restart.**
JSON status: `GET /api/status`
Force web admin on a non-dedicated run: `-WebAdmin`
## ServerConfig.ini
```ini
[/Script/VocationLife.VocationServerSettings]
ServerName=VocationLife Dedicated Server
GamePort=7777
AdminHttpPort=8080
AdminPassword=vocation
MaxPlayers=4
```

View File

@ -32,7 +32,7 @@ A life-simulation action RPG remake inspired by Fantasy Life, built in **Unreal
| Interact / Craft | E | X / Square | | Interact / Craft | E | X / Square |
| Attack | LMB | RT | | Attack | LMB | RT |
| Toggle camera (Top-Down / FP) | C | Menu | | Toggle camera (Top-Down / FP) | C | Menu |
| Quick save | I | Y / Triangle | | Multiplayer menu | M | — |
| Pause | Esc | Start | | Pause | Esc | Start |
### Pause menu shortcuts ### Pause menu shortcuts
@ -52,6 +52,8 @@ Source/VocationLife/ Core gameplay C++ (camera, input, inventory, crafting, m
Config/ Engine and server configuration Config/ Engine and server configuration
``` ```
See also: [Docs/Multiplayer.md](Docs/Multiplayer.md) (Steam, IP:Port, web admin).
## Dedicated Server Hosting ## Dedicated Server Hosting
Build the server target: Build the server target:

View File

@ -76,7 +76,7 @@ void AVocationHUD::DrawMainHUD()
} }
else else
{ {
const FString Controls = TEXT("WASD Move | Mouse Look | C Camera | E Interact/Craft | LMB Attack | I Save | Esc Pause"); const FString Controls = TEXT("WASD Move | C Camera | M Multiplayer | E Interact | LMB Attack | I Save | Esc Pause");
Canvas->DrawColor = FColor(200, 200, 200); Canvas->DrawColor = FColor(200, 200, 200);
Canvas->DrawText(GEngine->GetSmallFont(), Controls, 40.f * Scale, Canvas->ClipY - 60.f * Scale, Scale, Scale); Canvas->DrawText(GEngine->GetSmallFont(), Controls, 40.f * Scale, Canvas->ClipY - 60.f * Scale, Scale, Scale);
} }

View File

@ -0,0 +1,300 @@
// Copyright VocationLife Project. All Rights Reserved.
#include "VocationMainMenuWidget.h"
#include "VocationSessionSubsystem.h"
#include "Components/Button.h"
#include "Components/ComboBoxString.h"
#include "Components/EditableTextBox.h"
#include "Components/TextBlock.h"
#include "Components/VerticalBox.h"
#include "Components/HorizontalBox.h"
#include "Components/VerticalBoxSlot.h"
#include "Blueprint/WidgetTree.h"
#include "Engine/GameInstance.h"
namespace
{
UTextBlock* MakeLabel(UWidgetTree* Tree, const FName& Name, const FString& Text)
{
UTextBlock* Label = Tree->ConstructWidget<UTextBlock>(UTextBlock::StaticClass(), Name);
Label->SetText(FText::FromString(Text));
return Label;
}
}
void UVocationMainMenuWidget::NativeConstruct()
{
Super::NativeConstruct();
BuildUI();
RefreshMaps();
if (UGameInstance* GI = GetGameInstance())
{
if (UVocationSessionSubsystem* Sessions = GI->GetSubsystem<UVocationSessionSubsystem>())
{
Sessions->OnSessionSearchDetailed.AddDynamic(this, &UVocationMainMenuWidget::OnSessionSearchDetailed);
Sessions->OnSessionJoined.AddDynamic(this, &UVocationMainMenuWidget::OnSessionJoined);
SetStatus(FString::Printf(TEXT("Online subsystem: %s | Steam: %s"),
*Sessions->GetActiveSubsystemName(),
Sessions->IsSteamAvailable() ? TEXT("yes") : TEXT("no")));
}
}
}
void UVocationMainMenuWidget::NativeDestruct()
{
if (UGameInstance* GI = GetGameInstance())
{
if (UVocationSessionSubsystem* Sessions = GI->GetSubsystem<UVocationSessionSubsystem>())
{
Sessions->OnSessionSearchDetailed.RemoveDynamic(this, &UVocationMainMenuWidget::OnSessionSearchDetailed);
Sessions->OnSessionJoined.RemoveDynamic(this, &UVocationMainMenuWidget::OnSessionJoined);
}
}
Super::NativeDestruct();
}
void UVocationMainMenuWidget::BuildUI()
{
if (!WidgetTree)
{
return;
}
UVerticalBox* Column = WidgetTree->ConstructWidget<UVerticalBox>(UVerticalBox::StaticClass(), TEXT("Column"));
WidgetTree->RootWidget = Column;
auto AddToColumn = [Column](UWidget* Child)
{
if (UVerticalBoxSlot* Slot = Column->AddChildToVerticalBox(Child))
{
Slot->SetPadding(FMargin(12.f, 6.f));
}
};
AddToColumn(MakeLabel(WidgetTree, TEXT("Title"), TEXT("VocationLife Multiplayer")));
AddToColumn(MakeLabel(WidgetTree, TEXT("MapLabel"), TEXT("Map")));
MapCombo = WidgetTree->ConstructWidget<UComboBoxString>(UComboBoxString::StaticClass(), TEXT("MapCombo"));
AddToColumn(MapCombo);
AddToColumn(MakeLabel(WidgetTree, TEXT("BackendLabel"), TEXT("Network Backend")));
BackendCombo = WidgetTree->ConstructWidget<UComboBoxString>(UComboBoxString::StaticClass(), TEXT("BackendCombo"));
BackendCombo->AddOption(TEXT("Auto"));
BackendCombo->AddOption(TEXT("Steam"));
BackendCombo->AddOption(TEXT("LAN"));
BackendCombo->SetSelectedIndex(0);
AddToColumn(BackendCombo);
AddToColumn(MakeLabel(WidgetTree, TEXT("NameLabel"), TEXT("Server Name")));
ServerNameBox = WidgetTree->ConstructWidget<UEditableTextBox>(UEditableTextBox::StaticClass(), TEXT("ServerNameBox"));
ServerNameBox->SetText(FText::FromString(TEXT("VocationLife")));
AddToColumn(ServerNameBox);
AddToColumn(MakeLabel(WidgetTree, TEXT("PortLabel"), TEXT("Host Port")));
PortBox = WidgetTree->ConstructWidget<UEditableTextBox>(UEditableTextBox::StaticClass(), TEXT("PortBox"));
PortBox->SetText(FText::FromString(TEXT("7777")));
AddToColumn(PortBox);
AddToColumn(MakeLabel(WidgetTree, TEXT("MaxLabel"), TEXT("Max Players")));
MaxPlayersBox = WidgetTree->ConstructWidget<UEditableTextBox>(UEditableTextBox::StaticClass(), TEXT("MaxPlayersBox"));
MaxPlayersBox->SetText(FText::FromString(TEXT("4")));
AddToColumn(MaxPlayersBox);
HostButton = WidgetTree->ConstructWidget<UButton>(UButton::StaticClass(), TEXT("HostButton"));
{
UTextBlock* L = MakeLabel(WidgetTree, TEXT("HostBtnLabel"), TEXT("Host Game"));
HostButton->SetContent(L);
}
HostButton->OnClicked.AddDynamic(this, &UVocationMainMenuWidget::OnHostClicked);
AddToColumn(HostButton);
SoloButton = WidgetTree->ConstructWidget<UButton>(UButton::StaticClass(), TEXT("SoloButton"));
{
UTextBlock* L = MakeLabel(WidgetTree, TEXT("SoloBtnLabel"), TEXT("Play Solo (selected map)"));
SoloButton->SetContent(L);
}
SoloButton->OnClicked.AddDynamic(this, &UVocationMainMenuWidget::OnSoloClicked);
AddToColumn(SoloButton);
UHorizontalBox* FindRow = WidgetTree->ConstructWidget<UHorizontalBox>(UHorizontalBox::StaticClass(), TEXT("FindRow"));
FindSteamButton = WidgetTree->ConstructWidget<UButton>(UButton::StaticClass(), TEXT("FindSteamButton"));
FindSteamButton->SetContent(MakeLabel(WidgetTree, TEXT("FindSteamLbl"), TEXT("Find Steam")));
FindSteamButton->OnClicked.AddDynamic(this, &UVocationMainMenuWidget::OnFindSteamClicked);
FindLanButton = WidgetTree->ConstructWidget<UButton>(UButton::StaticClass(), TEXT("FindLanButton"));
FindLanButton->SetContent(MakeLabel(WidgetTree, TEXT("FindLanLbl"), TEXT("Find LAN")));
FindLanButton->OnClicked.AddDynamic(this, &UVocationMainMenuWidget::OnFindLanClicked);
FindRow->AddChildToHorizontalBox(FindSteamButton);
FindRow->AddChildToHorizontalBox(FindLanButton);
AddToColumn(FindRow);
AddToColumn(MakeLabel(WidgetTree, TEXT("SessionsLabel"), TEXT("Found Sessions")));
SessionCombo = WidgetTree->ConstructWidget<UComboBoxString>(UComboBoxString::StaticClass(), TEXT("SessionCombo"));
AddToColumn(SessionCombo);
JoinButton = WidgetTree->ConstructWidget<UButton>(UButton::StaticClass(), TEXT("JoinButton"));
JoinButton->SetContent(MakeLabel(WidgetTree, TEXT("JoinBtnLabel"), TEXT("Join Selected Session")));
JoinButton->OnClicked.AddDynamic(this, &UVocationMainMenuWidget::OnJoinSelectedClicked);
AddToColumn(JoinButton);
AddToColumn(MakeLabel(WidgetTree, TEXT("IpLabel"), TEXT("Direct Connect IP")));
IpBox = WidgetTree->ConstructWidget<UEditableTextBox>(UEditableTextBox::StaticClass(), TEXT("IpBox"));
IpBox->SetText(FText::FromString(TEXT("127.0.0.1")));
AddToColumn(IpBox);
AddToColumn(MakeLabel(WidgetTree, TEXT("JoinPortLabel"), TEXT("Direct Connect Port")));
JoinPortBox = WidgetTree->ConstructWidget<UEditableTextBox>(UEditableTextBox::StaticClass(), TEXT("JoinPortBox"));
JoinPortBox->SetText(FText::FromString(TEXT("7777")));
AddToColumn(JoinPortBox);
ConnectIpButton = WidgetTree->ConstructWidget<UButton>(UButton::StaticClass(), TEXT("ConnectIpButton"));
ConnectIpButton->SetContent(MakeLabel(WidgetTree, TEXT("ConnectIpLbl"), TEXT("Connect by IP:Port")));
ConnectIpButton->OnClicked.AddDynamic(this, &UVocationMainMenuWidget::OnConnectIpClicked);
AddToColumn(ConnectIpButton);
StatusText = MakeLabel(WidgetTree, TEXT("Status"), TEXT("Ready"));
AddToColumn(StatusText);
}
void UVocationMainMenuWidget::RefreshMaps()
{
CachedMaps = UVocationServerSettings::GetAvailableMaps();
if (!MapCombo)
{
return;
}
MapCombo->ClearOptions();
for (const FVocationMapOption& Map : CachedMaps)
{
MapCombo->AddOption(Map.DisplayName);
}
if (CachedMaps.Num() > 0)
{
MapCombo->SetSelectedIndex(0);
}
}
void UVocationMainMenuWidget::SetStatus(const FString& Message)
{
if (StatusText)
{
StatusText->SetText(FText::FromString(Message));
}
}
void UVocationMainMenuWidget::OnHostClicked()
{
UVocationSessionSubsystem* Sessions = GetGameInstance() ? GetGameInstance()->GetSubsystem<UVocationSessionSubsystem>() : nullptr;
if (!Sessions)
{
return;
}
const int32 MapIndex = MapCombo ? MapCombo->GetSelectedIndex() : 0;
const FString MapPath = CachedMaps.IsValidIndex(MapIndex) ? CachedMaps[MapIndex].MapPath : TEXT("/Engine/Maps/Entry");
const int32 Port = PortBox ? FCString::Atoi(*PortBox->GetText().ToString()) : 7777;
const int32 MaxPlayers = MaxPlayersBox ? FCString::Atoi(*MaxPlayersBox->GetText().ToString()) : 4;
const FString Name = ServerNameBox ? ServerNameBox->GetText().ToString() : TEXT("VocationLife");
EVocationNetBackend Backend = EVocationNetBackend::Auto;
const int32 BackendIndex = BackendCombo ? BackendCombo->GetSelectedIndex() : 0;
if (BackendIndex == 1) Backend = EVocationNetBackend::Steam;
if (BackendIndex == 2) Backend = EVocationNetBackend::LAN;
SetStatus(TEXT("Hosting..."));
Sessions->HostSession(MaxPlayers, Name, MapPath, Port, Backend, Backend == EVocationNetBackend::LAN);
RemoveFromParent();
}
void UVocationMainMenuWidget::OnFindSteamClicked()
{
if (UVocationSessionSubsystem* Sessions = GetGameInstance()->GetSubsystem<UVocationSessionSubsystem>())
{
SetStatus(TEXT("Searching Steam sessions..."));
Sessions->FindSessions(EVocationNetBackend::Steam, false);
}
}
void UVocationMainMenuWidget::OnFindLanClicked()
{
if (UVocationSessionSubsystem* Sessions = GetGameInstance()->GetSubsystem<UVocationSessionSubsystem>())
{
SetStatus(TEXT("Searching LAN sessions..."));
Sessions->FindSessions(EVocationNetBackend::LAN, true);
}
}
void UVocationMainMenuWidget::OnJoinSelectedClicked()
{
if (UVocationSessionSubsystem* Sessions = GetGameInstance()->GetSubsystem<UVocationSessionSubsystem>())
{
const int32 Index = SessionCombo ? SessionCombo->GetSelectedIndex() : INDEX_NONE;
if (Index == INDEX_NONE)
{
SetStatus(TEXT("No session selected"));
return;
}
SetStatus(TEXT("Joining session..."));
Sessions->JoinSessionByIndex(Index);
}
}
void UVocationMainMenuWidget::OnConnectIpClicked()
{
if (UVocationSessionSubsystem* Sessions = GetGameInstance()->GetSubsystem<UVocationSessionSubsystem>())
{
const FString IP = IpBox ? IpBox->GetText().ToString() : TEXT("127.0.0.1");
const int32 Port = JoinPortBox ? FCString::Atoi(*JoinPortBox->GetText().ToString()) : 7777;
SetStatus(FString::Printf(TEXT("Connecting to %s:%d ..."), *IP, Port));
Sessions->ConnectByIP(IP, Port);
RemoveFromParent();
}
}
void UVocationMainMenuWidget::OnSoloClicked()
{
if (UVocationSessionSubsystem* Sessions = GetGameInstance()->GetSubsystem<UVocationSessionSubsystem>())
{
const int32 MapIndex = MapCombo ? MapCombo->GetSelectedIndex() : 0;
const FString MapPath = CachedMaps.IsValidIndex(MapIndex) ? CachedMaps[MapIndex].MapPath : TEXT("/Engine/Maps/Entry");
Sessions->TravelToMap(MapPath, false, 7777);
RemoveFromParent();
}
}
void UVocationMainMenuWidget::OnSessionSearchDetailed(const TArray<FVocationSessionSearchEntry>& Sessions)
{
CachedSessions = Sessions;
if (!SessionCombo)
{
return;
}
SessionCombo->ClearOptions();
for (const FVocationSessionSearchEntry& Entry : Sessions)
{
SessionCombo->AddOption(FString::Printf(TEXT("%s [%s] %d/%d %s"),
*Entry.DisplayName,
*Entry.MapName,
Entry.MaxPlayers - Entry.OpenSlots,
Entry.MaxPlayers,
Entry.bIsLAN ? TEXT("LAN") : TEXT("Online")));
}
if (Sessions.Num() > 0)
{
SessionCombo->SetSelectedIndex(0);
SetStatus(FString::Printf(TEXT("Found %d session(s)"), Sessions.Num()));
}
else
{
SetStatus(TEXT("No sessions found"));
}
}
void UVocationMainMenuWidget::OnSessionJoined(bool bSuccess)
{
SetStatus(bSuccess ? TEXT("Join/host ok") : TEXT("Join/host failed"));
if (bSuccess)
{
RemoveFromParent();
}
}

View File

@ -5,6 +5,7 @@
#include "VocationHUD.h" #include "VocationHUD.h"
#include "VocationSessionSubsystem.h" #include "VocationSessionSubsystem.h"
#include "VocationGameInstance.h" #include "VocationGameInstance.h"
#include "VocationMainMenuWidget.h"
#include "EnhancedInputComponent.h" #include "EnhancedInputComponent.h"
#include "EnhancedInputSubsystems.h" #include "EnhancedInputSubsystems.h"
#include "InputAction.h" #include "InputAction.h"
@ -42,6 +43,12 @@ void AVocationPlayerController::BeginPlay()
{ {
GI->SetGraphicsPreset(GI->GetGraphicsPreset(), GI->IsRayTracingEnabled()); GI->SetGraphicsPreset(GI->GetGraphicsPreset(), GI->IsRayTracingEnabled());
} }
// Show multiplayer menu on first load (local player only).
if (IsLocalController() && !IsRunningDedicatedServer())
{
ToggleMultiplayerMenu();
}
} }
void AVocationPlayerController::SetupInputComponent() void AVocationPlayerController::SetupInputComponent()
@ -68,6 +75,7 @@ void AVocationPlayerController::SetupInputComponent()
InputComponent->BindKey(EKeys::F3, IE_Pressed, this, &AVocationPlayerController::ApplyHighGraphics).bConsumeInput = false; InputComponent->BindKey(EKeys::F3, IE_Pressed, this, &AVocationPlayerController::ApplyHighGraphics).bConsumeInput = false;
InputComponent->BindKey(EKeys::F4, IE_Pressed, this, &AVocationPlayerController::ApplyUltraGraphics).bConsumeInput = false; InputComponent->BindKey(EKeys::F4, IE_Pressed, this, &AVocationPlayerController::ApplyUltraGraphics).bConsumeInput = false;
InputComponent->BindKey(EKeys::F5, IE_Pressed, this, &AVocationPlayerController::ApplyRayTracingGraphics).bConsumeInput = false; InputComponent->BindKey(EKeys::F5, IE_Pressed, this, &AVocationPlayerController::ApplyRayTracingGraphics).bConsumeInput = false;
InputComponent->BindKey(EKeys::M, IE_Pressed, this, &AVocationPlayerController::ToggleMultiplayerMenu).bConsumeInput = false;
InputComponent->BindKey(EKeys::H, IE_Pressed, this, &AVocationPlayerController::HostCoopGame).bConsumeInput = false; InputComponent->BindKey(EKeys::H, IE_Pressed, this, &AVocationPlayerController::HostCoopGame).bConsumeInput = false;
InputComponent->BindKey(EKeys::J, IE_Pressed, this, &AVocationPlayerController::JoinFirstFoundSession).bConsumeInput = false; InputComponent->BindKey(EKeys::J, IE_Pressed, this, &AVocationPlayerController::JoinFirstFoundSession).bConsumeInput = false;
InputComponent->BindKey(EKeys::S, IE_Pressed, this, &AVocationPlayerController::QuickSave).bConsumeInput = false; InputComponent->BindKey(EKeys::S, IE_Pressed, this, &AVocationPlayerController::QuickSave).bConsumeInput = false;
@ -110,11 +118,33 @@ void AVocationPlayerController::ClearInteractionPrompt()
} }
} }
void AVocationPlayerController::ToggleMultiplayerMenu()
{
if (MultiplayerMenu && MultiplayerMenu->IsInViewport())
{
MultiplayerMenu->RemoveFromParent();
bShowMouseCursor = false;
SetInputMode(FInputModeGameOnly());
return;
}
MultiplayerMenu = CreateWidget<UVocationMainMenuWidget>(this, UVocationMainMenuWidget::StaticClass());
if (MultiplayerMenu)
{
MultiplayerMenu->AddToViewport(100);
bShowMouseCursor = true;
FInputModeGameAndUI Mode;
Mode.SetLockMouseToViewportBehavior(EMouseLockMode::DoNotLock);
Mode.SetHideCursorDuringCapture(false);
SetInputMode(Mode);
}
}
void AVocationPlayerController::HostCoopGame() void AVocationPlayerController::HostCoopGame()
{ {
if (UVocationSessionSubsystem* Sessions = GetGameInstance()->GetSubsystem<UVocationSessionSubsystem>()) if (UVocationSessionSubsystem* Sessions = GetGameInstance()->GetSubsystem<UVocationSessionSubsystem>())
{ {
Sessions->HostSession(4, TEXT("VocationLife")); Sessions->HostSession(4, TEXT("VocationLife"), TEXT("/Engine/Maps/Entry"), 7777, EVocationNetBackend::Auto, false);
} }
} }

View File

@ -0,0 +1,39 @@
// Copyright VocationLife Project. All Rights Reserved.
#include "VocationServerSettings.h"
#include "Misc/ConfigCacheIni.h"
#include "Misc/Paths.h"
void UVocationServerSettings::LoadFromDisk()
{
const FString ConfigPath = FPaths::ProjectConfigDir() / TEXT("ServerConfig.ini");
LoadConfig(GetClass(), *ConfigPath);
}
void UVocationServerSettings::SaveToDisk()
{
const FString ConfigPath = FPaths::ProjectConfigDir() / TEXT("ServerConfig.ini");
SaveConfig(CPF_Config, *ConfigPath);
}
TArray<FVocationMapOption> UVocationServerSettings::GetAvailableMaps()
{
TArray<FVocationMapOption> Maps;
FVocationMapOption Entry;
Entry.DisplayName = TEXT("Test Entry (Auto World)");
Entry.MapPath = TEXT("/Engine/Maps/Entry");
Maps.Add(Entry);
FVocationMapOption Intro;
Intro.DisplayName = TEXT("Intro Room (Template)");
Intro.MapPath = TEXT("/Game/DemoTemplate/_Core/Lvl_IntroRoom");
Maps.Add(Intro);
FVocationMapOption FirstPerson;
FirstPerson.DisplayName = TEXT("First Person Level");
FirstPerson.MapPath = TEXT("/Game/FirstPerson/Lvl_FirstPerson");
Maps.Add(FirstPerson);
return Maps;
}

View File

@ -2,54 +2,132 @@
#include "VocationSessionSubsystem.h" #include "VocationSessionSubsystem.h"
#include "OnlineSubsystem.h" #include "OnlineSubsystem.h"
#include "OnlineSubsystemUtils.h"
#include "OnlineSubsystemNames.h"
#include "OnlineSessionSettings.h" #include "OnlineSessionSettings.h"
#include "Interfaces/OnlineSessionInterface.h" #include "Interfaces/OnlineSessionInterface.h"
#include "Online/OnlineSessionNames.h"
#include "Kismet/GameplayStatics.h"
#include "GameFramework/PlayerController.h"
#include "Engine/World.h"
#include "Engine/LocalPlayer.h"
static const FName VOCATION_SESSION_NAME = TEXT("VocationLifeSession"); static const FName VOCATION_SESSION_NAME = TEXT("VocationLifeSession");
static const FName VOCATION_SETTING_SESSION_NAME = TEXT("SESSION_NAME");
static const FName VOCATION_SETTING_MAPNAME = TEXT("VLMAPNAME");
static const FName VOCATION_SEARCH_PRESENCE = TEXT("PRESENCESEARCH");
void UVocationSessionSubsystem::HostSession(int32 MaxPlayers, const FString& SessionName) void UVocationSessionSubsystem::Initialize(FSubsystemCollectionBase& Collection)
{ {
IOnlineSubsystem* OnlineSubsystem = IOnlineSubsystem::Get(); Super::Initialize(Collection);
}
bool UVocationSessionSubsystem::IsSteamAvailable() const
{
if (IOnlineSubsystem* Steam = IOnlineSubsystem::Get(STEAM_SUBSYSTEM))
{
return Steam->GetSessionInterface().IsValid();
}
return false;
}
FString UVocationSessionSubsystem::GetActiveSubsystemName() const
{
if (IOnlineSubsystem* Subsystem = IOnlineSubsystem::Get())
{
return Subsystem->GetSubsystemName().ToString();
}
return TEXT("None");
}
TArray<FVocationMapOption> UVocationSessionSubsystem::GetMapList() const
{
return UVocationServerSettings::GetAvailableMaps();
}
IOnlineSubsystem* UVocationSessionSubsystem::ResolveSubsystem(EVocationNetBackend Backend) const
{
switch (Backend)
{
case EVocationNetBackend::Steam:
return IOnlineSubsystem::Get(STEAM_SUBSYSTEM);
case EVocationNetBackend::LAN:
return IOnlineSubsystem::Get(NULL_SUBSYSTEM);
case EVocationNetBackend::Auto:
default:
if (IsSteamAvailable())
{
return IOnlineSubsystem::Get(STEAM_SUBSYSTEM);
}
return IOnlineSubsystem::Get();
}
}
void UVocationSessionSubsystem::HostSession(
int32 MaxPlayers,
const FString& SessionName,
const FString& MapPath,
int32 Port,
EVocationNetBackend Backend,
bool bLANOnly)
{
PendingMapPath = MapPath.IsEmpty() ? TEXT("/Engine/Maps/Entry") : MapPath;
PendingPort = Port > 0 ? Port : 7777;
IOnlineSubsystem* OnlineSubsystem = ResolveSubsystem(Backend);
if (!OnlineSubsystem) if (!OnlineSubsystem)
{ {
OnSessionJoined.Broadcast(false); // Fallback: start listen server without OSS
TravelToMap(PendingMapPath, true, PendingPort);
bIsHosting = true;
OnHostReady.Broadcast(true, FString::Printf(TEXT("%s?listen"), *PendingMapPath));
OnSessionJoined.Broadcast(true);
return; return;
} }
IOnlineSessionPtr Sessions = OnlineSubsystem->GetSessionInterface(); IOnlineSessionPtr Sessions = OnlineSubsystem->GetSessionInterface();
if (!Sessions.IsValid()) if (!Sessions.IsValid())
{ {
OnSessionJoined.Broadcast(false); TravelToMap(PendingMapPath, true, PendingPort);
bIsHosting = true;
OnHostReady.Broadcast(true, FString::Printf(TEXT("%s?listen"), *PendingMapPath));
OnSessionJoined.Broadcast(true);
return; return;
} }
Sessions->DestroySession(VOCATION_SESSION_NAME);
CreateSessionDelegateHandle = Sessions->AddOnCreateSessionCompleteDelegate_Handle( CreateSessionDelegateHandle = Sessions->AddOnCreateSessionCompleteDelegate_Handle(
FOnCreateSessionCompleteDelegate::CreateUObject(this, &UVocationSessionSubsystem::OnCreateSessionComplete)); FOnCreateSessionCompleteDelegate::CreateUObject(this, &UVocationSessionSubsystem::OnCreateSessionComplete));
TSharedRef<FOnlineSessionSettings> SessionSettings = MakeShared<FOnlineSessionSettings>(); const bool bUseLAN = bLANOnly || OnlineSubsystem->GetSubsystemName() == NULL_SUBSYSTEM;
SessionSettings->bIsLANMatch = true;
SessionSettings->NumPublicConnections = MaxPlayers;
SessionSettings->bShouldAdvertise = true;
SessionSettings->bUsesPresence = true;
SessionSettings->Set(FName(TEXT("SESSION_NAME")), SessionName, EOnlineDataAdvertisementType::ViaOnlineServiceAndPing);
const ULocalPlayer* LocalPlayer = GetWorld() && GetWorld()->GetFirstLocalPlayerFromController() FOnlineSessionSettings SessionSettings;
? GetWorld()->GetFirstLocalPlayerFromController() SessionSettings.bIsLANMatch = bUseLAN;
: nullptr; SessionSettings.NumPublicConnections = MaxPlayers;
SessionSettings.bShouldAdvertise = true;
SessionSettings.bAllowJoinInProgress = true;
SessionSettings.bAllowJoinViaPresence = !bUseLAN;
SessionSettings.bUsesPresence = !bUseLAN;
SessionSettings.bUseLobbiesIfAvailable = !bUseLAN;
SessionSettings.Set(VOCATION_SETTING_SESSION_NAME, SessionName, EOnlineDataAdvertisementType::ViaOnlineServiceAndPing);
SessionSettings.Set(VOCATION_SETTING_MAPNAME, PendingMapPath, EOnlineDataAdvertisementType::ViaOnlineServiceAndPing);
if (!Sessions->CreateSession(0, VOCATION_SESSION_NAME, *SessionSettings)) if (!Sessions->CreateSession(0, VOCATION_SESSION_NAME, SessionSettings))
{ {
Sessions->ClearOnCreateSessionCompleteDelegate_Handle(CreateSessionDelegateHandle); Sessions->ClearOnCreateSessionCompleteDelegate_Handle(CreateSessionDelegateHandle);
OnHostReady.Broadcast(false, TEXT(""));
OnSessionJoined.Broadcast(false); OnSessionJoined.Broadcast(false);
} }
} }
void UVocationSessionSubsystem::FindSessions() void UVocationSessionSubsystem::FindSessions(EVocationNetBackend Backend, bool bLANOnly)
{ {
IOnlineSubsystem* OnlineSubsystem = IOnlineSubsystem::Get(); IOnlineSubsystem* OnlineSubsystem = ResolveSubsystem(Backend);
if (!OnlineSubsystem) if (!OnlineSubsystem)
{ {
OnSessionSearchComplete.Broadcast({}); OnSessionSearchComplete.Broadcast({});
OnSessionSearchDetailed.Broadcast({});
return; return;
} }
@ -57,6 +135,7 @@ void UVocationSessionSubsystem::FindSessions()
if (!Sessions.IsValid()) if (!Sessions.IsValid())
{ {
OnSessionSearchComplete.Broadcast({}); OnSessionSearchComplete.Broadcast({});
OnSessionSearchDetailed.Broadcast({});
return; return;
} }
@ -64,20 +143,33 @@ void UVocationSessionSubsystem::FindSessions()
FOnFindSessionsCompleteDelegate::CreateUObject(this, &UVocationSessionSubsystem::OnFindSessionsComplete)); FOnFindSessionsCompleteDelegate::CreateUObject(this, &UVocationSessionSubsystem::OnFindSessionsComplete));
SessionSearch = MakeShared<FOnlineSessionSearch>(); SessionSearch = MakeShared<FOnlineSessionSearch>();
SessionSearch->bIsLanQuery = true; SessionSearch->bIsLanQuery = bLANOnly || OnlineSubsystem->GetSubsystemName() == NULL_SUBSYSTEM;
SessionSearch->MaxSearchResults = 20; SessionSearch->MaxSearchResults = 50;
SessionSearch->QuerySettings.Set(FName(TEXT("PRESENCESEARCH")), true, EOnlineComparisonOp::Equals); if (!SessionSearch->bIsLanQuery)
{
SessionSearch->QuerySettings.Set(VOCATION_SEARCH_PRESENCE, true, EOnlineComparisonOp::Equals);
}
if (!Sessions->FindSessions(0, SessionSearch.ToSharedRef())) if (!Sessions->FindSessions(0, SessionSearch.ToSharedRef()))
{ {
Sessions->ClearOnFindSessionsCompleteDelegate_Handle(FindSessionsDelegateHandle); Sessions->ClearOnFindSessionsCompleteDelegate_Handle(FindSessionsDelegateHandle);
OnSessionSearchComplete.Broadcast({}); OnSessionSearchComplete.Broadcast({});
OnSessionSearchDetailed.Broadcast({});
} }
} }
void UVocationSessionSubsystem::JoinSessionByIndex(int32 SessionIndex) void UVocationSessionSubsystem::JoinSessionByIndex(int32 SessionIndex)
{ {
IOnlineSubsystem* OnlineSubsystem = IOnlineSubsystem::Get(); IOnlineSubsystem* OnlineSubsystem = IOnlineSubsystem::Get();
if (IsSteamAvailable())
{
// Prefer the same subsystem that found the session if Steam is up
if (IOnlineSubsystem* Steam = IOnlineSubsystem::Get(STEAM_SUBSYSTEM))
{
OnlineSubsystem = Steam;
}
}
if (!OnlineSubsystem || !SessionSearch.IsValid()) if (!OnlineSubsystem || !SessionSearch.IsValid())
{ {
OnSessionJoined.Broadcast(false); OnSessionJoined.Broadcast(false);
@ -101,15 +193,66 @@ void UVocationSessionSubsystem::JoinSessionByIndex(int32 SessionIndex)
} }
} }
void UVocationSessionSubsystem::ConnectByIP(const FString& Address, int32 Port)
{
UWorld* World = GetWorld();
if (!World)
{
OnSessionJoined.Broadcast(false);
return;
}
APlayerController* PC = World->GetFirstPlayerController();
if (!PC)
{
OnSessionJoined.Broadcast(false);
return;
}
const int32 UsePort = Port > 0 ? Port : 7777;
FString TravelURL = Address;
if (!TravelURL.Contains(TEXT(":")))
{
TravelURL = FString::Printf(TEXT("%s:%d"), *Address, UsePort);
}
PC->ClientTravel(TravelURL, TRAVEL_Absolute);
OnSessionJoined.Broadcast(true);
}
void UVocationSessionSubsystem::TravelToMap(const FString& MapPath, bool bAsListenServer, int32 Port)
{
UWorld* World = GetWorld();
if (!World)
{
return;
}
FString Options;
if (bAsListenServer)
{
Options = FString::Printf(TEXT("listen?Port=%d"), Port > 0 ? Port : 7777);
}
UGameplayStatics::OpenLevel(World, FName(*MapPath), true, Options);
}
void UVocationSessionSubsystem::DestroySession() void UVocationSessionSubsystem::DestroySession()
{ {
if (IOnlineSubsystem* OnlineSubsystem = IOnlineSubsystem::Get()) auto DestroyOn = [](IOnlineSubsystem* Subsystem)
{ {
if (IOnlineSessionPtr Sessions = OnlineSubsystem->GetSessionInterface()) if (Subsystem)
{
if (IOnlineSessionPtr Sessions = Subsystem->GetSessionInterface())
{ {
Sessions->DestroySession(VOCATION_SESSION_NAME); Sessions->DestroySession(VOCATION_SESSION_NAME);
} }
} }
};
DestroyOn(IOnlineSubsystem::Get());
DestroyOn(IOnlineSubsystem::Get(STEAM_SUBSYSTEM));
DestroyOn(IOnlineSubsystem::Get(NULL_SUBSYSTEM));
bIsHosting = false; bIsHosting = false;
} }
@ -122,45 +265,125 @@ void UVocationSessionSubsystem::OnCreateSessionComplete(FName SessionName, bool
Sessions->ClearOnCreateSessionCompleteDelegate_Handle(CreateSessionDelegateHandle); Sessions->ClearOnCreateSessionCompleteDelegate_Handle(CreateSessionDelegateHandle);
} }
} }
if (IOnlineSubsystem* Steam = IOnlineSubsystem::Get(STEAM_SUBSYSTEM))
{
if (IOnlineSessionPtr Sessions = Steam->GetSessionInterface())
{
Sessions->ClearOnCreateSessionCompleteDelegate_Handle(CreateSessionDelegateHandle);
}
}
bIsHosting = bWasSuccessful; bIsHosting = bWasSuccessful;
if (bWasSuccessful)
{
TravelToMap(PendingMapPath, true, PendingPort);
OnHostReady.Broadcast(true, FString::Printf(TEXT("%s?listen"), *PendingMapPath));
}
else
{
OnHostReady.Broadcast(false, TEXT(""));
}
OnSessionJoined.Broadcast(bWasSuccessful); OnSessionJoined.Broadcast(bWasSuccessful);
} }
void UVocationSessionSubsystem::OnFindSessionsComplete(bool bWasSuccessful) void UVocationSessionSubsystem::OnFindSessionsComplete(bool bWasSuccessful)
{ {
TArray<FString> SessionNames; TArray<FString> SessionNames;
TArray<FVocationSessionSearchEntry> Detailed;
if (bWasSuccessful && SessionSearch.IsValid()) if (bWasSuccessful && SessionSearch.IsValid())
{ {
for (const FOnlineSessionSearchResult& Result : SessionSearch->SearchResults) for (const FOnlineSessionSearchResult& Result : SessionSearch->SearchResults)
{ {
FString Name; FVocationSessionSearchEntry Entry;
Result.Session.SessionSettings.Get(FName(TEXT("SESSION_NAME")), Name); Result.Session.SessionSettings.Get(VOCATION_SETTING_SESSION_NAME, Entry.DisplayName);
SessionNames.Add(Name.IsEmpty() ? TEXT("VocationLife Server") : Name); Result.Session.SessionSettings.Get(VOCATION_SETTING_MAPNAME, Entry.MapName);
Entry.MaxPlayers = Result.Session.SessionSettings.NumPublicConnections;
Entry.OpenSlots = Result.Session.NumOpenPublicConnections;
Entry.bIsLAN = Result.Session.SessionSettings.bIsLANMatch;
if (Entry.DisplayName.IsEmpty())
{
Entry.DisplayName = TEXT("VocationLife Server");
}
SessionNames.Add(Entry.DisplayName);
Detailed.Add(Entry);
} }
} }
if (IOnlineSubsystem* OnlineSubsystem = IOnlineSubsystem::Get()) auto ClearFind = [](IOnlineSubsystem* Subsystem, FDelegateHandle& Handle)
{ {
if (IOnlineSessionPtr Sessions = OnlineSubsystem->GetSessionInterface()) if (Subsystem)
{ {
Sessions->ClearOnFindSessionsCompleteDelegate_Handle(FindSessionsDelegateHandle); if (IOnlineSessionPtr Sessions = Subsystem->GetSessionInterface())
{
Sessions->ClearOnFindSessionsCompleteDelegate_Handle(Handle);
} }
} }
};
ClearFind(IOnlineSubsystem::Get(), FindSessionsDelegateHandle);
ClearFind(IOnlineSubsystem::Get(STEAM_SUBSYSTEM), FindSessionsDelegateHandle);
OnSessionSearchComplete.Broadcast(SessionNames); OnSessionSearchComplete.Broadcast(SessionNames);
OnSessionSearchDetailed.Broadcast(Detailed);
} }
void UVocationSessionSubsystem::OnJoinSessionComplete(FName SessionName, EOnJoinSessionCompleteResult::Type Result) void UVocationSessionSubsystem::OnJoinSessionComplete(FName SessionName, EOnJoinSessionCompleteResult::Type Result)
{ {
if (IOnlineSubsystem* OnlineSubsystem = IOnlineSubsystem::Get()) auto ClearJoin = [](IOnlineSubsystem* Subsystem, FDelegateHandle& Handle)
{ {
if (IOnlineSessionPtr Sessions = OnlineSubsystem->GetSessionInterface()) if (Subsystem)
{ {
Sessions->ClearOnJoinSessionCompleteDelegate_Handle(JoinSessionDelegateHandle); if (IOnlineSessionPtr Sessions = Subsystem->GetSessionInterface())
{
Sessions->ClearOnJoinSessionCompleteDelegate_Handle(Handle);
} }
} }
};
ClearJoin(IOnlineSubsystem::Get(), JoinSessionDelegateHandle);
ClearJoin(IOnlineSubsystem::Get(STEAM_SUBSYSTEM), JoinSessionDelegateHandle);
const bool bSuccess = Result == EOnJoinSessionCompleteResult::Success; const bool bSuccess = Result == EOnJoinSessionCompleteResult::Success;
if (bSuccess)
{
TravelAfterSuccessfulJoin();
}
OnSessionJoined.Broadcast(bSuccess); OnSessionJoined.Broadcast(bSuccess);
} }
void UVocationSessionSubsystem::TravelAfterSuccessfulJoin()
{
UWorld* World = GetWorld();
if (!World)
{
return;
}
APlayerController* PC = World->GetFirstPlayerController();
if (!PC)
{
return;
}
FString ConnectInfo;
IOnlineSubsystem* Subsystems[] = {
IOnlineSubsystem::Get(STEAM_SUBSYSTEM),
IOnlineSubsystem::Get(),
IOnlineSubsystem::Get(NULL_SUBSYSTEM)
};
for (IOnlineSubsystem* Subsystem : Subsystems)
{
if (!Subsystem)
{
continue;
}
if (IOnlineSessionPtr Sessions = Subsystem->GetSessionInterface())
{
if (Sessions->GetResolvedConnectString(VOCATION_SESSION_NAME, ConnectInfo))
{
PC->ClientTravel(ConnectInfo, TRAVEL_Absolute);
return;
}
}
}
}

View File

@ -0,0 +1,382 @@
// Copyright VocationLife Project. All Rights Reserved.
#include "VocationWebAdminSubsystem.h"
#include "Sockets.h"
#include "SocketSubsystem.h"
#include "IPAddress.h"
#include "Misc/CommandLine.h"
#include "Engine/Engine.h"
void UVocationWebAdminSubsystem::Initialize(FSubsystemCollectionBase& Collection)
{
Super::Initialize(Collection);
ServerSettings = NewObject<UVocationServerSettings>(this);
ServerSettings->LoadFromDisk();
if (IsRunningDedicatedServer() || FParse::Param(FCommandLine::Get(), TEXT("WebAdmin")))
{
StartAdminServer(ServerSettings->AdminHttpPort);
}
}
void UVocationWebAdminSubsystem::Deinitialize()
{
StopAdminServer();
Super::Deinitialize();
}
void UVocationWebAdminSubsystem::Tick(float DeltaTime)
{
if (bIsRunning)
{
AcceptConnections();
}
}
TStatId UVocationWebAdminSubsystem::GetStatId() const
{
RETURN_QUICK_DECLARE_CYCLE_STAT(UVocationWebAdminSubsystem, STATGROUP_Tickables);
}
bool UVocationWebAdminSubsystem::IsTickable() const
{
return bIsRunning;
}
bool UVocationWebAdminSubsystem::StartAdminServer(int32 Port)
{
StopAdminServer();
BoundPort = Port > 0 ? Port : 8080;
ISocketSubsystem* SocketSubsystem = ISocketSubsystem::Get(PLATFORM_SOCKETSUBSYSTEM);
if (!SocketSubsystem)
{
return false;
}
ListenSocket = MakeShareable(SocketSubsystem->CreateSocket(NAME_Stream, TEXT("VocationWebAdmin"), false));
if (!ListenSocket.IsValid())
{
return false;
}
ListenSocket->SetReuseAddr(true);
ListenSocket->SetNonBlocking(true);
TSharedRef<FInternetAddr> Addr = SocketSubsystem->CreateInternetAddr();
bool bIsValid = false;
Addr->SetIp(TEXT("0.0.0.0"), bIsValid);
Addr->SetPort(BoundPort);
if (!ListenSocket->Bind(*Addr) || !ListenSocket->Listen(8))
{
ListenSocket->Close();
ListenSocket.Reset();
return false;
}
bIsRunning = true;
UE_LOG(LogTemp, Log, TEXT("VocationLife Web Admin listening on port %d"), BoundPort);
return true;
}
void UVocationWebAdminSubsystem::StopAdminServer()
{
if (ListenSocket.IsValid())
{
ListenSocket->Close();
ListenSocket.Reset();
}
bIsRunning = false;
}
void UVocationWebAdminSubsystem::AcceptConnections()
{
if (!ListenSocket.IsValid())
{
return;
}
bool bPending = false;
if (!ListenSocket->HasPendingConnection(bPending) || !bPending)
{
return;
}
FSocket* Client = ListenSocket->Accept(TEXT("VocationWebAdminClient"));
if (Client)
{
HandleClient(Client);
Client->Close();
ISocketSubsystem::Get(PLATFORM_SOCKETSUBSYSTEM)->DestroySocket(Client);
}
}
bool UVocationWebAdminSubsystem::ParseHttpRequest(const FString& Raw, FString& OutMethod, FString& OutPath, FString& OutBody)
{
TArray<FString> Lines;
Raw.ParseIntoArrayLines(Lines, false);
if (Lines.Num() == 0)
{
return false;
}
TArray<FString> RequestParts;
Lines[0].ParseIntoArrayWS(RequestParts);
if (RequestParts.Num() < 2)
{
return false;
}
OutMethod = RequestParts[0];
OutPath = RequestParts[1];
const int32 HeaderEnd = Raw.Find(TEXT("\r\n\r\n"));
if (HeaderEnd != INDEX_NONE)
{
OutBody = Raw.Mid(HeaderEnd + 4);
}
return true;
}
FString UVocationWebAdminSubsystem::UrlDecode(const FString& Encoded)
{
FString Result;
Result.Reserve(Encoded.Len());
for (int32 i = 0; i < Encoded.Len(); ++i)
{
const TCHAR C = Encoded[i];
if (C == TEXT('+'))
{
Result.AppendChar(TEXT(' '));
}
else if (C == TEXT('%') && i + 2 < Encoded.Len())
{
const int32 Hi = FParse::HexDigit(Encoded[i + 1]);
const int32 Lo = FParse::HexDigit(Encoded[i + 2]);
if (Hi >= 0 && Lo >= 0)
{
Result.AppendChar(static_cast<TCHAR>((Hi << 4) | Lo));
i += 2;
}
else
{
Result.AppendChar(C);
}
}
else
{
Result.AppendChar(C);
}
}
return Result;
}
bool UVocationWebAdminSubsystem::ApplyFormBody(const FString& Body, FString& OutMessage)
{
if (!ServerSettings)
{
OutMessage = TEXT("No settings object");
return false;
}
TArray<FString> Pairs;
Body.ParseIntoArray(Pairs, TEXT("&"), true);
FString SubmittedPassword;
TSet<FString> SeenKeys;
for (const FString& Pair : Pairs)
{
FString Key, Value;
if (!Pair.Split(TEXT("="), &Key, &Value))
{
continue;
}
Key = UrlDecode(Key);
Value = UrlDecode(Value);
SeenKeys.Add(Key);
if (Key == TEXT("password"))
{
SubmittedPassword = Value;
}
}
if (SubmittedPassword != ServerSettings->AdminPassword)
{
OutMessage = TEXT("Invalid admin password");
return false;
}
// Unchecked HTML checkboxes are omitted from POST bodies.
ServerSettings->bLANOnly = SeenKeys.Contains(TEXT("bLANOnly"));
ServerSettings->bUseSteam = SeenKeys.Contains(TEXT("bUseSteam"));
ServerSettings->GameRules.bFriendlyFire = SeenKeys.Contains(TEXT("bFriendlyFire"));
ServerSettings->GameRules.bAllowVocationSwitch = SeenKeys.Contains(TEXT("bAllowVocationSwitch"));
for (const FString& Pair : Pairs)
{
FString Key, Value;
if (!Pair.Split(TEXT("="), &Key, &Value))
{
continue;
}
Key = UrlDecode(Key);
Value = UrlDecode(Value);
if (Key == TEXT("ServerName"))
{
ServerSettings->ServerName = Value;
}
else if (Key == TEXT("GamePort"))
{
const int32 NewPort = FCString::Atoi(*Value);
if (NewPort > 0 && NewPort != ServerSettings->GamePort)
{
ServerSettings->GamePort = NewPort;
bPendingPortRestartNotice = true;
}
}
else if (Key == TEXT("AdminHttpPort"))
{
ServerSettings->AdminHttpPort = FCString::Atoi(*Value);
}
else if (Key == TEXT("MaxPlayers"))
{
ServerSettings->MaxPlayers = FMath::Clamp(FCString::Atoi(*Value), 1, 64);
}
else if (Key == TEXT("DefaultMapPath"))
{
ServerSettings->DefaultMapPath = Value;
}
else if (Key == TEXT("DayLengthMinutes"))
{
ServerSettings->GameRules.DayLengthMinutes = FCString::Atof(*Value);
}
else if (Key == TEXT("MiningRespawnSeconds"))
{
ServerSettings->GameRules.MiningRespawnSeconds = FCString::Atof(*Value);
}
}
ServerSettings->SaveToDisk();
OutMessage = bPendingPortRestartNotice
? TEXT("Saved. Game port changes apply after server restart.")
: TEXT("Settings saved.");
return true;
}
FString UVocationWebAdminSubsystem::BuildJsonStatus() const
{
if (!ServerSettings)
{
return TEXT("{}");
}
auto Escape = [](const FString& In) -> FString
{
return In.Replace(TEXT("\\"), TEXT("\\\\")).Replace(TEXT("\""), TEXT("\\\""));
};
return FString::Printf(
TEXT("{\"serverName\":\"%s\",\"gamePort\":%d,\"adminHttpPort\":%d,\"maxPlayers\":%d,\"map\":\"%s\",\"lanOnly\":%s,\"useSteam\":%s,\"friendlyFire\":%s}"),
*Escape(ServerSettings->ServerName),
ServerSettings->GamePort,
ServerSettings->AdminHttpPort,
ServerSettings->MaxPlayers,
*Escape(ServerSettings->DefaultMapPath),
ServerSettings->bLANOnly ? TEXT("true") : TEXT("false"),
ServerSettings->bUseSteam ? TEXT("true") : TEXT("false"),
ServerSettings->GameRules.bFriendlyFire ? TEXT("true") : TEXT("false"));
}
FString UVocationWebAdminSubsystem::BuildHtmlPage() const
{
const FVocationGameRules& Rules = ServerSettings->GameRules;
FString Html;
Html += TEXT("<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>VocationLife Server Admin</title>");
Html += TEXT("<style>body{font-family:system-ui,sans-serif;background:#1a1f24;color:#e8ecef;margin:2rem;}");
Html += TEXT("form{background:#252b33;padding:1.5rem;border-radius:12px;max-width:640px;}");
Html += TEXT("label{display:block;margin:.75rem 0 .25rem;}");
Html += TEXT("input[type=text],input[type=number],input[type=password]{width:100%;padding:.5rem;border-radius:6px;border:1px solid #3a4450;background:#12161a;color:#fff;}");
Html += TEXT("button{margin-top:1rem;padding:.6rem 1.2rem;border:0;border-radius:8px;background:#3d8bfd;color:#fff;font-weight:600;cursor:pointer;}");
Html += TEXT(".hint{opacity:.7;font-size:.9rem;margin-top:1rem;}h1{margin-top:0;}</style></head><body>");
Html += TEXT("<h1>VocationLife Server Admin</h1><form method=\"POST\" action=\"/settings\">");
Html += TEXT("<label>Admin Password</label><input type=\"password\" name=\"password\" required>");
Html += FString::Printf(TEXT("<label>Server Name</label><input type=\"text\" name=\"ServerName\" value=\"%s\">"), *ServerSettings->ServerName);
Html += FString::Printf(TEXT("<label>Game Port</label><input type=\"number\" name=\"GamePort\" value=\"%d\">"), ServerSettings->GamePort);
Html += FString::Printf(TEXT("<label>Admin HTTP Port</label><input type=\"number\" name=\"AdminHttpPort\" value=\"%d\">"), ServerSettings->AdminHttpPort);
Html += FString::Printf(TEXT("<label>Max Players</label><input type=\"number\" name=\"MaxPlayers\" value=\"%d\">"), ServerSettings->MaxPlayers);
Html += FString::Printf(TEXT("<label>Default Map Path</label><input type=\"text\" name=\"DefaultMapPath\" value=\"%s\">"), *ServerSettings->DefaultMapPath);
Html += FString::Printf(TEXT("<label><input type=\"checkbox\" name=\"bLANOnly\" %s> LAN only</label>"), ServerSettings->bLANOnly ? TEXT("checked") : TEXT(""));
Html += FString::Printf(TEXT("<label><input type=\"checkbox\" name=\"bUseSteam\" %s> Prefer Steam</label>"), ServerSettings->bUseSteam ? TEXT("checked") : TEXT(""));
Html += TEXT("<hr><h2>Game Rules</h2>");
Html += FString::Printf(TEXT("<label><input type=\"checkbox\" name=\"bFriendlyFire\" %s> Friendly Fire</label>"), Rules.bFriendlyFire ? TEXT("checked") : TEXT(""));
Html += FString::Printf(TEXT("<label><input type=\"checkbox\" name=\"bAllowVocationSwitch\" %s> Allow Vocation Switch</label>"), Rules.bAllowVocationSwitch ? TEXT("checked") : TEXT(""));
Html += FString::Printf(TEXT("<label>Day Length (minutes)</label><input type=\"number\" step=\"0.1\" name=\"DayLengthMinutes\" value=\"%.1f\">"), Rules.DayLengthMinutes);
Html += FString::Printf(TEXT("<label>Mining Respawn (seconds)</label><input type=\"number\" step=\"0.1\" name=\"MiningRespawnSeconds\" value=\"%.1f\">"), Rules.MiningRespawnSeconds);
Html += TEXT("<button type=\"submit\">Save Settings</button></form>");
Html += FString::Printf(TEXT("<p class=\"hint\">Game port changes require a server restart. Admin panel port: %d</p></body></html>"), BoundPort);
return Html;
}
void UVocationWebAdminSubsystem::HandleClient(FSocket* ClientSocket)
{
if (!ClientSocket)
{
return;
}
TArray<uint8> Data;
Data.SetNumUninitialized(8192);
int32 BytesRead = 0;
if (!ClientSocket->Recv(Data.GetData(), Data.Num(), BytesRead) || BytesRead <= 0)
{
return;
}
Data.SetNum(BytesRead);
const FUTF8ToTCHAR Converter(reinterpret_cast<const ANSICHAR*>(Data.GetData()), BytesRead);
const FString Request(Converter.Length(), Converter.Get());
FString Method, Path, Body;
if (!ParseHttpRequest(Request, Method, Path, Body))
{
return;
}
FString Status = TEXT("200 OK");
FString ContentType = TEXT("text/html; charset=utf-8");
FString ResponseBody;
if (Path.StartsWith(TEXT("/api/status")))
{
ContentType = TEXT("application/json");
ResponseBody = BuildJsonStatus();
}
else if (Method == TEXT("POST") && Path.StartsWith(TEXT("/settings")))
{
FString Message;
ApplyFormBody(Body, Message);
ResponseBody = FString::Printf(
TEXT("<!DOCTYPE html><html><body style='font-family:sans-serif;background:#1a1f24;color:#fff;padding:2rem'><p>%s</p><p><a href='/' style='color:#3d8bfd'>Back</a></p></body></html>"),
*Message);
}
else
{
ResponseBody = BuildHtmlPage();
}
const FTCHARToUTF8 BodyUtf8(*ResponseBody);
const FString Header = FString::Printf(
TEXT("HTTP/1.1 %s\r\nContent-Type: %s\r\nContent-Length: %d\r\nConnection: close\r\n\r\n"),
*Status,
*ContentType,
BodyUtf8.Length());
FTCHARToUTF8 HeaderUtf8(*Header);
int32 Sent = 0;
ClientSocket->Send(reinterpret_cast<const uint8*>(HeaderUtf8.Get()), HeaderUtf8.Length(), Sent);
ClientSocket->Send(reinterpret_cast<const uint8*>(BodyUtf8.Get()), BodyUtf8.Length(), Sent);
}

View File

@ -0,0 +1,105 @@
// Copyright VocationLife Project. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "Blueprint/UserWidget.h"
#include "VocationSessionSubsystem.h"
#include "VocationMainMenuWidget.generated.h"
class UEditableTextBox;
class UComboBoxString;
class UButton;
class UTextBlock;
class UVerticalBox;
class UHorizontalBox;
/**
* In-game multiplayer menu: map select, Steam/LAN host, IP:Port join.
*/
UCLASS()
class VOCATIONLIFE_API UVocationMainMenuWidget : public UUserWidget
{
GENERATED_BODY()
public:
virtual void NativeConstruct() override;
virtual void NativeDestruct() override;
protected:
void BuildUI();
void RefreshMaps();
void SetStatus(const FString& Message);
UFUNCTION()
void OnHostClicked();
UFUNCTION()
void OnFindSteamClicked();
UFUNCTION()
void OnFindLanClicked();
UFUNCTION()
void OnJoinSelectedClicked();
UFUNCTION()
void OnConnectIpClicked();
UFUNCTION()
void OnSoloClicked();
UFUNCTION()
void OnSessionSearchDetailed(const TArray<FVocationSessionSearchEntry>& Sessions);
UFUNCTION()
void OnSessionJoined(bool bSuccess);
UPROPERTY()
TObjectPtr<UComboBoxString> MapCombo;
UPROPERTY()
TObjectPtr<UComboBoxString> BackendCombo;
UPROPERTY()
TObjectPtr<UComboBoxString> SessionCombo;
UPROPERTY()
TObjectPtr<UEditableTextBox> ServerNameBox;
UPROPERTY()
TObjectPtr<UEditableTextBox> PortBox;
UPROPERTY()
TObjectPtr<UEditableTextBox> IpBox;
UPROPERTY()
TObjectPtr<UEditableTextBox> JoinPortBox;
UPROPERTY()
TObjectPtr<UEditableTextBox> MaxPlayersBox;
UPROPERTY()
TObjectPtr<UTextBlock> StatusText;
UPROPERTY()
TObjectPtr<UButton> HostButton;
UPROPERTY()
TObjectPtr<UButton> FindSteamButton;
UPROPERTY()
TObjectPtr<UButton> FindLanButton;
UPROPERTY()
TObjectPtr<UButton> JoinButton;
UPROPERTY()
TObjectPtr<UButton> ConnectIpButton;
UPROPERTY()
TObjectPtr<UButton> SoloButton;
TArray<FVocationMapOption> CachedMaps;
TArray<FVocationSessionSearchEntry> CachedSessions;
};

View File

@ -32,6 +32,9 @@ public:
UFUNCTION(BlueprintCallable, Category = "VocationLife|UI") UFUNCTION(BlueprintCallable, Category = "VocationLife|UI")
void ClearInteractionPrompt(); void ClearInteractionPrompt();
UFUNCTION(BlueprintCallable, Category = "VocationLife|UI")
void ToggleMultiplayerMenu();
UFUNCTION(BlueprintCallable, Category = "VocationLife|Session") UFUNCTION(BlueprintCallable, Category = "VocationLife|Session")
void HostCoopGame(); void HostCoopGame();
@ -107,6 +110,9 @@ protected:
UPROPERTY() UPROPERTY()
TObjectPtr<AVocationHUD> VocationHUD; TObjectPtr<AVocationHUD> VocationHUD;
UPROPERTY()
TObjectPtr<class UVocationMainMenuWidget> MultiplayerMenu;
UPROPERTY(EditDefaultsOnly, Category = "VocationLife|Input") UPROPERTY(EditDefaultsOnly, Category = "VocationLife|Input")
int32 SharedMappingPriority = 0; int32 SharedMappingPriority = 0;

View File

@ -0,0 +1,87 @@
// Copyright VocationLife Project. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "UObject/Object.h"
#include "VocationServerSettings.generated.h"
USTRUCT(BlueprintType)
struct FVocationMapOption
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Map")
FString DisplayName;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Map")
FString MapPath;
};
USTRUCT(BlueprintType)
struct FVocationGameRules
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Rules")
bool bFriendlyFire = false;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Rules")
float DayLengthMinutes = 20.f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Rules")
bool bAllowVocationSwitch = true;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Rules")
int32 StartingOre = 0;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Rules")
float MiningRespawnSeconds = 30.f;
};
/**
* Runtime + config-backed dedicated server settings.
* Loaded from Config/ServerConfig.ini and mutable via the web admin.
*/
UCLASS(Config = ServerConfig, DefaultConfig)
class VOCATIONLIFE_API UVocationServerSettings : public UObject
{
GENERATED_BODY()
public:
UPROPERTY(Config, EditAnywhere, BlueprintReadWrite, Category = "Server")
FString ServerName = TEXT("VocationLife Dedicated Server");
UPROPERTY(Config, EditAnywhere, BlueprintReadWrite, Category = "Server")
int32 MaxPlayers = 4;
UPROPERTY(Config, EditAnywhere, BlueprintReadWrite, Category = "Server")
int32 GamePort = 7777;
UPROPERTY(Config, EditAnywhere, BlueprintReadWrite, Category = "Server")
int32 AdminHttpPort = 8080;
UPROPERTY(Config, EditAnywhere, BlueprintReadWrite, Category = "Server")
bool bLANOnly = false;
UPROPERTY(Config, EditAnywhere, BlueprintReadWrite, Category = "Server")
bool bUseSteam = true;
UPROPERTY(Config, EditAnywhere, BlueprintReadWrite, Category = "Server")
FString AdminPassword = TEXT("vocation");
UPROPERTY(Config, EditAnywhere, BlueprintReadWrite, Category = "Server")
FString DefaultMapPath = TEXT("/Engine/Maps/Entry");
UPROPERTY(Config, EditAnywhere, BlueprintReadWrite, Category = "Server")
FVocationGameRules GameRules;
UFUNCTION(BlueprintCallable, Category = "VocationLife|Server")
void LoadFromDisk();
UFUNCTION(BlueprintCallable, Category = "VocationLife|Server")
void SaveToDisk();
UFUNCTION(BlueprintCallable, Category = "VocationLife|Server")
static TArray<FVocationMapOption> GetAvailableMaps();
};

View File

@ -5,10 +5,44 @@
#include "CoreMinimal.h" #include "CoreMinimal.h"
#include "Subsystems/GameInstanceSubsystem.h" #include "Subsystems/GameInstanceSubsystem.h"
#include "Interfaces/OnlineSessionInterface.h" #include "Interfaces/OnlineSessionInterface.h"
#include "VocationServerSettings.h"
#include "VocationSessionSubsystem.generated.h" #include "VocationSessionSubsystem.generated.h"
class IOnlineSubsystem;
UENUM(BlueprintType)
enum class EVocationNetBackend : uint8
{
Auto UMETA(DisplayName = "Auto (Steam if available)"),
Steam UMETA(DisplayName = "Steam"),
LAN UMETA(DisplayName = "LAN / Null")
};
USTRUCT(BlueprintType)
struct FVocationSessionSearchEntry
{
GENERATED_BODY()
UPROPERTY(BlueprintReadOnly, Category = "Session")
FString DisplayName;
UPROPERTY(BlueprintReadOnly, Category = "Session")
FString MapName;
UPROPERTY(BlueprintReadOnly, Category = "Session")
int32 MaxPlayers = 0;
UPROPERTY(BlueprintReadOnly, Category = "Session")
int32 OpenSlots = 0;
UPROPERTY(BlueprintReadOnly, Category = "Session")
bool bIsLAN = false;
};
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnSessionSearchComplete, const TArray<FString>&, SessionNames); DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnSessionSearchComplete, const TArray<FString>&, SessionNames);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnSessionSearchDetailed, const TArray<FVocationSessionSearchEntry>&, Sessions);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnSessionJoined, bool, bSuccess); DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnSessionJoined, bool, bSuccess);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnHostReady, bool, bSuccess, const FString&, TravelURL);
UCLASS() UCLASS()
class VOCATIONLIFE_API UVocationSessionSubsystem : public UGameInstanceSubsystem class VOCATIONLIFE_API UVocationSessionSubsystem : public UGameInstanceSubsystem
@ -16,31 +50,62 @@ class VOCATIONLIFE_API UVocationSessionSubsystem : public UGameInstanceSubsystem
GENERATED_BODY() GENERATED_BODY()
public: public:
UFUNCTION(BlueprintCallable, Category = "VocationLife|Session") virtual void Initialize(FSubsystemCollectionBase& Collection) override;
void HostSession(int32 MaxPlayers = 4, const FString& SessionName = TEXT("VocationLife"));
UFUNCTION(BlueprintCallable, Category = "VocationLife|Session") UFUNCTION(BlueprintCallable, Category = "VocationLife|Session")
void FindSessions(); void HostSession(
int32 MaxPlayers = 4,
const FString& SessionName = TEXT("VocationLife"),
const FString& MapPath = TEXT("/Engine/Maps/Entry"),
int32 Port = 7777,
EVocationNetBackend Backend = EVocationNetBackend::Auto,
bool bLANOnly = false);
UFUNCTION(BlueprintCallable, Category = "VocationLife|Session")
void FindSessions(EVocationNetBackend Backend = EVocationNetBackend::Auto, bool bLANOnly = false);
UFUNCTION(BlueprintCallable, Category = "VocationLife|Session") UFUNCTION(BlueprintCallable, Category = "VocationLife|Session")
void JoinSessionByIndex(int32 SessionIndex); void JoinSessionByIndex(int32 SessionIndex);
UFUNCTION(BlueprintCallable, Category = "VocationLife|Session")
void ConnectByIP(const FString& Address, int32 Port = 7777);
UFUNCTION(BlueprintCallable, Category = "VocationLife|Session")
void TravelToMap(const FString& MapPath, bool bAsListenServer = false, int32 Port = 7777);
UFUNCTION(BlueprintCallable, Category = "VocationLife|Session") UFUNCTION(BlueprintCallable, Category = "VocationLife|Session")
void DestroySession(); void DestroySession();
UFUNCTION(BlueprintCallable, Category = "VocationLife|Session") UFUNCTION(BlueprintCallable, Category = "VocationLife|Session")
bool IsHosting() const { return bIsHosting; } bool IsHosting() const { return bIsHosting; }
UFUNCTION(BlueprintCallable, Category = "VocationLife|Session")
bool IsSteamAvailable() const;
UFUNCTION(BlueprintCallable, Category = "VocationLife|Session")
FString GetActiveSubsystemName() const;
UFUNCTION(BlueprintCallable, Category = "VocationLife|Session")
TArray<FVocationMapOption> GetMapList() const;
UPROPERTY(BlueprintAssignable, Category = "VocationLife|Session") UPROPERTY(BlueprintAssignable, Category = "VocationLife|Session")
FOnSessionSearchComplete OnSessionSearchComplete; FOnSessionSearchComplete OnSessionSearchComplete;
UPROPERTY(BlueprintAssignable, Category = "VocationLife|Session")
FOnSessionSearchDetailed OnSessionSearchDetailed;
UPROPERTY(BlueprintAssignable, Category = "VocationLife|Session") UPROPERTY(BlueprintAssignable, Category = "VocationLife|Session")
FOnSessionJoined OnSessionJoined; FOnSessionJoined OnSessionJoined;
UPROPERTY(BlueprintAssignable, Category = "VocationLife|Session")
FOnHostReady OnHostReady;
protected: protected:
IOnlineSubsystem* ResolveSubsystem(EVocationNetBackend Backend) const;
void OnCreateSessionComplete(FName SessionName, bool bWasSuccessful); void OnCreateSessionComplete(FName SessionName, bool bWasSuccessful);
void OnFindSessionsComplete(bool bWasSuccessful); void OnFindSessionsComplete(bool bWasSuccessful);
void OnJoinSessionComplete(FName SessionName, EOnJoinSessionCompleteResult::Type Result); void OnJoinSessionComplete(FName SessionName, EOnJoinSessionCompleteResult::Type Result);
void TravelAfterSuccessfulJoin();
FDelegateHandle CreateSessionDelegateHandle; FDelegateHandle CreateSessionDelegateHandle;
FDelegateHandle FindSessionsDelegateHandle; FDelegateHandle FindSessionsDelegateHandle;
@ -48,4 +113,6 @@ protected:
TSharedPtr<class FOnlineSessionSearch> SessionSearch; TSharedPtr<class FOnlineSessionSearch> SessionSearch;
bool bIsHosting = false; bool bIsHosting = false;
FString PendingMapPath = TEXT("/Engine/Maps/Entry");
int32 PendingPort = 7777;
}; };

View File

@ -0,0 +1,60 @@
// Copyright VocationLife Project. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "Subsystems/GameInstanceSubsystem.h"
#include "VocationServerSettings.h"
#include "VocationWebAdminSubsystem.generated.h"
class FSocket;
class FTcpListener;
class FInternetAddr;
/**
* Lightweight HTTP admin panel for dedicated / headless servers.
* Default: http://<server-ip>:8080
*/
UCLASS()
class VOCATIONLIFE_API UVocationWebAdminSubsystem : public UGameInstanceSubsystem, public FTickableGameObject
{
GENERATED_BODY()
public:
virtual void Initialize(FSubsystemCollectionBase& Collection) override;
virtual void Deinitialize() override;
virtual void Tick(float DeltaTime) override;
virtual TStatId GetStatId() const override;
virtual bool IsTickable() const override;
virtual bool IsTickableInEditor() const override { return false; }
UFUNCTION(BlueprintCallable, Category = "VocationLife|WebAdmin")
bool StartAdminServer(int32 Port = 8080);
UFUNCTION(BlueprintCallable, Category = "VocationLife|WebAdmin")
void StopAdminServer();
UFUNCTION(BlueprintCallable, Category = "VocationLife|WebAdmin")
bool IsAdminRunning() const { return bIsRunning; }
UFUNCTION(BlueprintCallable, Category = "VocationLife|WebAdmin")
UVocationServerSettings* GetServerSettings() const { return ServerSettings; }
protected:
void AcceptConnections();
void HandleClient(FSocket* ClientSocket);
FString BuildHtmlPage() const;
FString BuildJsonStatus() const;
bool ApplyFormBody(const FString& Body, FString& OutMessage);
static bool ParseHttpRequest(const FString& Raw, FString& OutMethod, FString& OutPath, FString& OutBody);
static FString UrlDecode(const FString& Encoded);
UPROPERTY()
TObjectPtr<UVocationServerSettings> ServerSettings;
TSharedPtr<FSocket> ListenSocket;
bool bIsRunning = false;
int32 BoundPort = 8080;
bool bPendingPortRestartNotice = false;
};

View File

@ -21,7 +21,9 @@ public class VocationLife : ModuleRules
"OnlineSubsystem", "OnlineSubsystem",
"OnlineSubsystemUtils", "OnlineSubsystemUtils",
"Json", "Json",
"JsonUtilities" "JsonUtilities",
"Sockets",
"Networking"
}); });
if (Target.Platform == UnrealTargetPlatform.Win64 || if (Target.Platform == UnrealTargetPlatform.Win64 ||