diff --git a/Config/DefaultEngine.ini b/Config/DefaultEngine.ini index 28496b3..c95e9c9 100644 --- a/Config/DefaultEngine.ini +++ b/Config/DefaultEngine.ini @@ -242,9 +242,12 @@ bEnabled=true bEnabled=true SteamDevAppId=480 bInitServerOnClient=true +bVACEnabled=0 [/Script/Engine.GameEngine] +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] bUseBuildIdOverride=false diff --git a/Config/ServerConfig.ini b/Config/ServerConfig.ini index 25cecf3..33c00a5 100644 --- a/Config/ServerConfig.ini +++ b/Config/ServerConfig.ini @@ -1,5 +1,10 @@ [/Script/VocationLife.VocationServerSettings] ServerName=VocationLife Dedicated Server MaxPlayers=4 -Port=7777 -bLANOnly=true +GamePort=7777 +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) diff --git a/Docs/Multiplayer.md b/Docs/Multiplayer.md new file mode 100644 index 0000000..ef82976 --- /dev/null +++ b/Docs/Multiplayer.md @@ -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 +``` diff --git a/README.md b/README.md index 1922524..505e33f 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ A life-simulation action RPG remake inspired by Fantasy Life, built in **Unreal | Interact / Craft | E | X / Square | | Attack | LMB | RT | | Toggle camera (Top-Down / FP) | C | Menu | -| Quick save | I | Y / Triangle | +| Multiplayer menu | M | — | | Pause | Esc | Start | ### Pause menu shortcuts @@ -52,6 +52,8 @@ Source/VocationLife/ Core gameplay C++ (camera, input, inventory, crafting, m Config/ Engine and server configuration ``` +See also: [Docs/Multiplayer.md](Docs/Multiplayer.md) (Steam, IP:Port, web admin). + ## Dedicated Server Hosting Build the server target: diff --git a/Source/VocationLife/Private/VocationHUD.cpp b/Source/VocationLife/Private/VocationHUD.cpp index baee8f7..56fcb68 100644 --- a/Source/VocationLife/Private/VocationHUD.cpp +++ b/Source/VocationLife/Private/VocationHUD.cpp @@ -76,7 +76,7 @@ void AVocationHUD::DrawMainHUD() } 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->DrawText(GEngine->GetSmallFont(), Controls, 40.f * Scale, Canvas->ClipY - 60.f * Scale, Scale, Scale); } diff --git a/Source/VocationLife/Private/VocationMainMenuWidget.cpp b/Source/VocationLife/Private/VocationMainMenuWidget.cpp new file mode 100644 index 0000000..3184551 --- /dev/null +++ b/Source/VocationLife/Private/VocationMainMenuWidget.cpp @@ -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::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()) + { + 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()) + { + 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::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::StaticClass(), TEXT("MapCombo")); + AddToColumn(MapCombo); + + AddToColumn(MakeLabel(WidgetTree, TEXT("BackendLabel"), TEXT("Network Backend"))); + BackendCombo = WidgetTree->ConstructWidget(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::StaticClass(), TEXT("ServerNameBox")); + ServerNameBox->SetText(FText::FromString(TEXT("VocationLife"))); + AddToColumn(ServerNameBox); + + AddToColumn(MakeLabel(WidgetTree, TEXT("PortLabel"), TEXT("Host Port"))); + PortBox = WidgetTree->ConstructWidget(UEditableTextBox::StaticClass(), TEXT("PortBox")); + PortBox->SetText(FText::FromString(TEXT("7777"))); + AddToColumn(PortBox); + + AddToColumn(MakeLabel(WidgetTree, TEXT("MaxLabel"), TEXT("Max Players"))); + MaxPlayersBox = WidgetTree->ConstructWidget(UEditableTextBox::StaticClass(), TEXT("MaxPlayersBox")); + MaxPlayersBox->SetText(FText::FromString(TEXT("4"))); + AddToColumn(MaxPlayersBox); + + HostButton = WidgetTree->ConstructWidget(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::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::StaticClass(), TEXT("FindRow")); + FindSteamButton = WidgetTree->ConstructWidget(UButton::StaticClass(), TEXT("FindSteamButton")); + FindSteamButton->SetContent(MakeLabel(WidgetTree, TEXT("FindSteamLbl"), TEXT("Find Steam"))); + FindSteamButton->OnClicked.AddDynamic(this, &UVocationMainMenuWidget::OnFindSteamClicked); + FindLanButton = WidgetTree->ConstructWidget(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::StaticClass(), TEXT("SessionCombo")); + AddToColumn(SessionCombo); + + JoinButton = WidgetTree->ConstructWidget(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::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::StaticClass(), TEXT("JoinPortBox")); + JoinPortBox->SetText(FText::FromString(TEXT("7777"))); + AddToColumn(JoinPortBox); + + ConnectIpButton = WidgetTree->ConstructWidget(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() : 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()) + { + SetStatus(TEXT("Searching Steam sessions...")); + Sessions->FindSessions(EVocationNetBackend::Steam, false); + } +} + +void UVocationMainMenuWidget::OnFindLanClicked() +{ + if (UVocationSessionSubsystem* Sessions = GetGameInstance()->GetSubsystem()) + { + SetStatus(TEXT("Searching LAN sessions...")); + Sessions->FindSessions(EVocationNetBackend::LAN, true); + } +} + +void UVocationMainMenuWidget::OnJoinSelectedClicked() +{ + if (UVocationSessionSubsystem* Sessions = GetGameInstance()->GetSubsystem()) + { + 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()) + { + 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()) + { + 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& 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(); + } +} diff --git a/Source/VocationLife/Private/VocationPlayerController.cpp b/Source/VocationLife/Private/VocationPlayerController.cpp index 129f7c3..ac23f91 100644 --- a/Source/VocationLife/Private/VocationPlayerController.cpp +++ b/Source/VocationLife/Private/VocationPlayerController.cpp @@ -5,6 +5,7 @@ #include "VocationHUD.h" #include "VocationSessionSubsystem.h" #include "VocationGameInstance.h" +#include "VocationMainMenuWidget.h" #include "EnhancedInputComponent.h" #include "EnhancedInputSubsystems.h" #include "InputAction.h" @@ -42,6 +43,12 @@ void AVocationPlayerController::BeginPlay() { GI->SetGraphicsPreset(GI->GetGraphicsPreset(), GI->IsRayTracingEnabled()); } + + // Show multiplayer menu on first load (local player only). + if (IsLocalController() && !IsRunningDedicatedServer()) + { + ToggleMultiplayerMenu(); + } } void AVocationPlayerController::SetupInputComponent() @@ -68,6 +75,7 @@ void AVocationPlayerController::SetupInputComponent() 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::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::J, IE_Pressed, this, &AVocationPlayerController::JoinFirstFoundSession).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(this, UVocationMainMenuWidget::StaticClass()); + if (MultiplayerMenu) + { + MultiplayerMenu->AddToViewport(100); + bShowMouseCursor = true; + FInputModeGameAndUI Mode; + Mode.SetLockMouseToViewportBehavior(EMouseLockMode::DoNotLock); + Mode.SetHideCursorDuringCapture(false); + SetInputMode(Mode); + } +} + void AVocationPlayerController::HostCoopGame() { if (UVocationSessionSubsystem* Sessions = GetGameInstance()->GetSubsystem()) { - Sessions->HostSession(4, TEXT("VocationLife")); + Sessions->HostSession(4, TEXT("VocationLife"), TEXT("/Engine/Maps/Entry"), 7777, EVocationNetBackend::Auto, false); } } diff --git a/Source/VocationLife/Private/VocationServerSettings.cpp b/Source/VocationLife/Private/VocationServerSettings.cpp new file mode 100644 index 0000000..674b027 --- /dev/null +++ b/Source/VocationLife/Private/VocationServerSettings.cpp @@ -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 UVocationServerSettings::GetAvailableMaps() +{ + TArray 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; +} diff --git a/Source/VocationLife/Private/VocationSessionSubsystem.cpp b/Source/VocationLife/Private/VocationSessionSubsystem.cpp index 709f53e..5009a10 100644 --- a/Source/VocationLife/Private/VocationSessionSubsystem.cpp +++ b/Source/VocationLife/Private/VocationSessionSubsystem.cpp @@ -2,54 +2,132 @@ #include "VocationSessionSubsystem.h" #include "OnlineSubsystem.h" +#include "OnlineSubsystemUtils.h" +#include "OnlineSubsystemNames.h" #include "OnlineSessionSettings.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_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 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) { - 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; } IOnlineSessionPtr Sessions = OnlineSubsystem->GetSessionInterface(); 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; } + Sessions->DestroySession(VOCATION_SESSION_NAME); + CreateSessionDelegateHandle = Sessions->AddOnCreateSessionCompleteDelegate_Handle( FOnCreateSessionCompleteDelegate::CreateUObject(this, &UVocationSessionSubsystem::OnCreateSessionComplete)); - TSharedRef SessionSettings = MakeShared(); - SessionSettings->bIsLANMatch = true; - SessionSettings->NumPublicConnections = MaxPlayers; - SessionSettings->bShouldAdvertise = true; - SessionSettings->bUsesPresence = true; - SessionSettings->Set(FName(TEXT("SESSION_NAME")), SessionName, EOnlineDataAdvertisementType::ViaOnlineServiceAndPing); + const bool bUseLAN = bLANOnly || OnlineSubsystem->GetSubsystemName() == NULL_SUBSYSTEM; - const ULocalPlayer* LocalPlayer = GetWorld() && GetWorld()->GetFirstLocalPlayerFromController() - ? GetWorld()->GetFirstLocalPlayerFromController() - : nullptr; + FOnlineSessionSettings SessionSettings; + SessionSettings.bIsLANMatch = bUseLAN; + 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); + OnHostReady.Broadcast(false, TEXT("")); OnSessionJoined.Broadcast(false); } } -void UVocationSessionSubsystem::FindSessions() +void UVocationSessionSubsystem::FindSessions(EVocationNetBackend Backend, bool bLANOnly) { - IOnlineSubsystem* OnlineSubsystem = IOnlineSubsystem::Get(); + IOnlineSubsystem* OnlineSubsystem = ResolveSubsystem(Backend); if (!OnlineSubsystem) { OnSessionSearchComplete.Broadcast({}); + OnSessionSearchDetailed.Broadcast({}); return; } @@ -57,6 +135,7 @@ void UVocationSessionSubsystem::FindSessions() if (!Sessions.IsValid()) { OnSessionSearchComplete.Broadcast({}); + OnSessionSearchDetailed.Broadcast({}); return; } @@ -64,20 +143,33 @@ void UVocationSessionSubsystem::FindSessions() FOnFindSessionsCompleteDelegate::CreateUObject(this, &UVocationSessionSubsystem::OnFindSessionsComplete)); SessionSearch = MakeShared(); - SessionSearch->bIsLanQuery = true; - SessionSearch->MaxSearchResults = 20; - SessionSearch->QuerySettings.Set(FName(TEXT("PRESENCESEARCH")), true, EOnlineComparisonOp::Equals); + SessionSearch->bIsLanQuery = bLANOnly || OnlineSubsystem->GetSubsystemName() == NULL_SUBSYSTEM; + SessionSearch->MaxSearchResults = 50; + if (!SessionSearch->bIsLanQuery) + { + SessionSearch->QuerySettings.Set(VOCATION_SEARCH_PRESENCE, true, EOnlineComparisonOp::Equals); + } if (!Sessions->FindSessions(0, SessionSearch.ToSharedRef())) { Sessions->ClearOnFindSessionsCompleteDelegate_Handle(FindSessionsDelegateHandle); OnSessionSearchComplete.Broadcast({}); + OnSessionSearchDetailed.Broadcast({}); } } void UVocationSessionSubsystem::JoinSessionByIndex(int32 SessionIndex) { 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()) { 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() { - if (IOnlineSubsystem* OnlineSubsystem = IOnlineSubsystem::Get()) + auto DestroyOn = [](IOnlineSubsystem* Subsystem) { - if (IOnlineSessionPtr Sessions = OnlineSubsystem->GetSessionInterface()) + if (Subsystem) { - Sessions->DestroySession(VOCATION_SESSION_NAME); + if (IOnlineSessionPtr Sessions = Subsystem->GetSessionInterface()) + { + Sessions->DestroySession(VOCATION_SESSION_NAME); + } } - } + }; + + DestroyOn(IOnlineSubsystem::Get()); + DestroyOn(IOnlineSubsystem::Get(STEAM_SUBSYSTEM)); + DestroyOn(IOnlineSubsystem::Get(NULL_SUBSYSTEM)); bIsHosting = false; } @@ -122,45 +265,125 @@ void UVocationSessionSubsystem::OnCreateSessionComplete(FName SessionName, bool Sessions->ClearOnCreateSessionCompleteDelegate_Handle(CreateSessionDelegateHandle); } } + if (IOnlineSubsystem* Steam = IOnlineSubsystem::Get(STEAM_SUBSYSTEM)) + { + if (IOnlineSessionPtr Sessions = Steam->GetSessionInterface()) + { + Sessions->ClearOnCreateSessionCompleteDelegate_Handle(CreateSessionDelegateHandle); + } + } 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); } void UVocationSessionSubsystem::OnFindSessionsComplete(bool bWasSuccessful) { TArray SessionNames; + TArray Detailed; + if (bWasSuccessful && SessionSearch.IsValid()) { for (const FOnlineSessionSearchResult& Result : SessionSearch->SearchResults) { - FString Name; - Result.Session.SessionSettings.Get(FName(TEXT("SESSION_NAME")), Name); - SessionNames.Add(Name.IsEmpty() ? TEXT("VocationLife Server") : Name); + FVocationSessionSearchEntry Entry; + Result.Session.SessionSettings.Get(VOCATION_SETTING_SESSION_NAME, Entry.DisplayName); + 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); + OnSessionSearchDetailed.Broadcast(Detailed); } 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; + if (bSuccess) + { + TravelAfterSuccessfulJoin(); + } 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; + } + } + } +} diff --git a/Source/VocationLife/Private/VocationWebAdminSubsystem.cpp b/Source/VocationLife/Private/VocationWebAdminSubsystem.cpp new file mode 100644 index 0000000..9d73ab5 --- /dev/null +++ b/Source/VocationLife/Private/VocationWebAdminSubsystem.cpp @@ -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(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 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 Lines; + Raw.ParseIntoArrayLines(Lines, false); + if (Lines.Num() == 0) + { + return false; + } + + TArray 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((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 Pairs; + Body.ParseIntoArray(Pairs, TEXT("&"), true); + + FString SubmittedPassword; + TSet 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("VocationLife Server Admin"); + Html += TEXT(""); + Html += TEXT("

VocationLife Server Admin

"); + Html += TEXT(""); + Html += FString::Printf(TEXT(""), *ServerSettings->ServerName); + Html += FString::Printf(TEXT(""), ServerSettings->GamePort); + Html += FString::Printf(TEXT(""), ServerSettings->AdminHttpPort); + Html += FString::Printf(TEXT(""), ServerSettings->MaxPlayers); + Html += FString::Printf(TEXT(""), *ServerSettings->DefaultMapPath); + Html += FString::Printf(TEXT(""), ServerSettings->bLANOnly ? TEXT("checked") : TEXT("")); + Html += FString::Printf(TEXT(""), ServerSettings->bUseSteam ? TEXT("checked") : TEXT("")); + Html += TEXT("

Game Rules

"); + Html += FString::Printf(TEXT(""), Rules.bFriendlyFire ? TEXT("checked") : TEXT("")); + Html += FString::Printf(TEXT(""), Rules.bAllowVocationSwitch ? TEXT("checked") : TEXT("")); + Html += FString::Printf(TEXT(""), Rules.DayLengthMinutes); + Html += FString::Printf(TEXT(""), Rules.MiningRespawnSeconds); + Html += TEXT("
"); + Html += FString::Printf(TEXT("

Game port changes require a server restart. Admin panel port: %d

"), BoundPort); + return Html; +} + +void UVocationWebAdminSubsystem::HandleClient(FSocket* ClientSocket) +{ + if (!ClientSocket) + { + return; + } + + TArray 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(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("

%s

Back

"), + *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(HeaderUtf8.Get()), HeaderUtf8.Length(), Sent); + ClientSocket->Send(reinterpret_cast(BodyUtf8.Get()), BodyUtf8.Length(), Sent); +} diff --git a/Source/VocationLife/Public/VocationMainMenuWidget.h b/Source/VocationLife/Public/VocationMainMenuWidget.h new file mode 100644 index 0000000..51a7c0b --- /dev/null +++ b/Source/VocationLife/Public/VocationMainMenuWidget.h @@ -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& Sessions); + + UFUNCTION() + void OnSessionJoined(bool bSuccess); + + UPROPERTY() + TObjectPtr MapCombo; + + UPROPERTY() + TObjectPtr BackendCombo; + + UPROPERTY() + TObjectPtr SessionCombo; + + UPROPERTY() + TObjectPtr ServerNameBox; + + UPROPERTY() + TObjectPtr PortBox; + + UPROPERTY() + TObjectPtr IpBox; + + UPROPERTY() + TObjectPtr JoinPortBox; + + UPROPERTY() + TObjectPtr MaxPlayersBox; + + UPROPERTY() + TObjectPtr StatusText; + + UPROPERTY() + TObjectPtr HostButton; + + UPROPERTY() + TObjectPtr FindSteamButton; + + UPROPERTY() + TObjectPtr FindLanButton; + + UPROPERTY() + TObjectPtr JoinButton; + + UPROPERTY() + TObjectPtr ConnectIpButton; + + UPROPERTY() + TObjectPtr SoloButton; + + TArray CachedMaps; + TArray CachedSessions; +}; diff --git a/Source/VocationLife/Public/VocationPlayerController.h b/Source/VocationLife/Public/VocationPlayerController.h index c34dc89..5dd2e48 100644 --- a/Source/VocationLife/Public/VocationPlayerController.h +++ b/Source/VocationLife/Public/VocationPlayerController.h @@ -32,6 +32,9 @@ public: UFUNCTION(BlueprintCallable, Category = "VocationLife|UI") void ClearInteractionPrompt(); + UFUNCTION(BlueprintCallable, Category = "VocationLife|UI") + void ToggleMultiplayerMenu(); + UFUNCTION(BlueprintCallable, Category = "VocationLife|Session") void HostCoopGame(); @@ -107,6 +110,9 @@ protected: UPROPERTY() TObjectPtr VocationHUD; + UPROPERTY() + TObjectPtr MultiplayerMenu; + UPROPERTY(EditDefaultsOnly, Category = "VocationLife|Input") int32 SharedMappingPriority = 0; diff --git a/Source/VocationLife/Public/VocationServerSettings.h b/Source/VocationLife/Public/VocationServerSettings.h new file mode 100644 index 0000000..037ce53 --- /dev/null +++ b/Source/VocationLife/Public/VocationServerSettings.h @@ -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 GetAvailableMaps(); +}; diff --git a/Source/VocationLife/Public/VocationSessionSubsystem.h b/Source/VocationLife/Public/VocationSessionSubsystem.h index 8e28e45..f724635 100644 --- a/Source/VocationLife/Public/VocationSessionSubsystem.h +++ b/Source/VocationLife/Public/VocationSessionSubsystem.h @@ -5,10 +5,44 @@ #include "CoreMinimal.h" #include "Subsystems/GameInstanceSubsystem.h" #include "Interfaces/OnlineSessionInterface.h" +#include "VocationServerSettings.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&, SessionNames); +DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnSessionSearchDetailed, const TArray&, Sessions); DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnSessionJoined, bool, bSuccess); +DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnHostReady, bool, bSuccess, const FString&, TravelURL); UCLASS() class VOCATIONLIFE_API UVocationSessionSubsystem : public UGameInstanceSubsystem @@ -16,31 +50,62 @@ class VOCATIONLIFE_API UVocationSessionSubsystem : public UGameInstanceSubsystem GENERATED_BODY() public: - UFUNCTION(BlueprintCallable, Category = "VocationLife|Session") - void HostSession(int32 MaxPlayers = 4, const FString& SessionName = TEXT("VocationLife")); + virtual void Initialize(FSubsystemCollectionBase& Collection) override; 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") 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") void DestroySession(); UFUNCTION(BlueprintCallable, Category = "VocationLife|Session") 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 GetMapList() const; + UPROPERTY(BlueprintAssignable, Category = "VocationLife|Session") FOnSessionSearchComplete OnSessionSearchComplete; + UPROPERTY(BlueprintAssignable, Category = "VocationLife|Session") + FOnSessionSearchDetailed OnSessionSearchDetailed; + UPROPERTY(BlueprintAssignable, Category = "VocationLife|Session") FOnSessionJoined OnSessionJoined; + UPROPERTY(BlueprintAssignable, Category = "VocationLife|Session") + FOnHostReady OnHostReady; + protected: + IOnlineSubsystem* ResolveSubsystem(EVocationNetBackend Backend) const; void OnCreateSessionComplete(FName SessionName, bool bWasSuccessful); void OnFindSessionsComplete(bool bWasSuccessful); void OnJoinSessionComplete(FName SessionName, EOnJoinSessionCompleteResult::Type Result); + void TravelAfterSuccessfulJoin(); FDelegateHandle CreateSessionDelegateHandle; FDelegateHandle FindSessionsDelegateHandle; @@ -48,4 +113,6 @@ protected: TSharedPtr SessionSearch; bool bIsHosting = false; + FString PendingMapPath = TEXT("/Engine/Maps/Entry"); + int32 PendingPort = 7777; }; diff --git a/Source/VocationLife/Public/VocationWebAdminSubsystem.h b/Source/VocationLife/Public/VocationWebAdminSubsystem.h new file mode 100644 index 0000000..c465eff --- /dev/null +++ b/Source/VocationLife/Public/VocationWebAdminSubsystem.h @@ -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://: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 ServerSettings; + + TSharedPtr ListenSocket; + bool bIsRunning = false; + int32 BoundPort = 8080; + bool bPendingPortRestartNotice = false; +}; diff --git a/Source/VocationLife/VocationLife.Build.cs b/Source/VocationLife/VocationLife.Build.cs index 013c541..4e22b97 100644 --- a/Source/VocationLife/VocationLife.Build.cs +++ b/Source/VocationLife/VocationLife.Build.cs @@ -21,7 +21,9 @@ public class VocationLife : ModuleRules "OnlineSubsystem", "OnlineSubsystemUtils", "Json", - "JsonUtilities" + "JsonUtilities", + "Sockets", + "Networking" }); if (Target.Platform == UnrealTargetPlatform.Win64 ||