(Feat) Add mulitplayer

This commit is contained in:
2026-08-03 10:30:47 +02:00
parent 41d6e6a7f3
commit a4cc59a411
41 changed files with 3418 additions and 125 deletions
@@ -16,9 +16,14 @@
#include "InteractionPromptWidget.h"
#include "CraftingComponent.h"
#include "InventoryScreenWidget.h"
#include "Engine/LocalPlayer.h"
#include "GameFramework/PlayerState.h"
#include "Interfaces/OnlineIdentityInterface.h"
#include "Kismet/GameplayStatics.h"
#include "OnlineSubsystem.h"
#include "PauseMenuWidget.h"
#include "ScreenFadeComponent.h"
#include "SessionSubsystem.h"
#include "SettingsMenuWidget.h"
#include "StorageContainer.h"
#include "SurvivalStatsWidget.h"
@@ -87,6 +92,23 @@ void AFpsPlayerController::BeginPlay()
{
SetInputMode(FInputModeGameOnly());
SetShowMouseCursor(false);
// Annonce notre pseudo Steam. Seul le client connait le sien : l'API
// d'identite ne renseigne que l'utilisateur local de chaque machine.
const FString Nickname = ResolveLocalPlayerNickname();
if (!Nickname.IsEmpty())
{
// L'hote est deja l'autorite : passer par la RPC marcherait, mais
// l'appel direct evite un aller-retour pour rien.
if (HasAuthority())
{
Server_SetPlayerNickname_Implementation(Nickname);
}
else
{
Server_SetPlayerNickname(Nickname);
}
}
}
// Le delegue se declenche a la fin de CHAQUE fondu : c'est
@@ -143,6 +165,21 @@ void AFpsPlayerController::HandlePawnDied()
}
void AFpsPlayerController::RespawnPlayer()
{
// Le fondu de retour est purement local : il appartient a l'ecran de ce
// joueur, et il doit remonter meme si la reponse du serveur tarde.
ScreenFade->FadeIn(StartupFadeDuration);
if (!HasAuthority())
{
Server_RequestRespawn();
return;
}
Server_RequestRespawn_Implementation();
}
void AFpsPlayerController::Server_RequestRespawn_Implementation()
{
// On depossede AVANT de detruire : detruire un pawn encore possede laisse
// le controller dans un etat incoherent le temps d'une frame.
@@ -164,8 +201,6 @@ void AFpsPlayerController::RespawnPlayer()
{
UE_LOG(LogTemp, Error, TEXT("AFpsPlayerController : aucun GameMode, impossible de reapparaitre."));
}
ScreenFade->FadeIn(StartupFadeDuration);
}
void AFpsPlayerController::SetupInputComponent()
@@ -456,6 +491,32 @@ void AFpsPlayerController::OpenCraftingAtStation(ECraftingStation Station)
return;
}
// Appelee depuis Interact_Implementation, donc sur le SERVEUR. Pour un
// client distant on accorde le poste ici -- c'est ce controller-la que
// Server_Craft interrogera -- et on demande a l'ecran de s'ouvrir la-bas.
// Chez un hote, IsLocalController() est vrai et on passe tout droit.
if (!IsLocalController())
{
GrantStation(Station);
Client_OpenCraftingAtStation(Station);
return;
}
GrantStation(Station);
SetInventoryScreenOpen(true, /*bShowCraftingTab=*/true);
}
void AFpsPlayerController::Client_OpenCraftingAtStation_Implementation(ECraftingStation Station)
{
// On rejoue le chemin local : accorder le poste sur NOTRE copie du
// composant, sans quoi la grille de recettes filtrerait sur {Hands} et
// l'onglet paraitrait vide devant un etabli.
GrantStation(Station);
SetInventoryScreenOpen(true, /*bShowCraftingTab=*/true);
}
void AFpsPlayerController::GrantStation(ECraftingStation Station)
{
// Deja ouvert sur un autre poste : on rend le precedent avant d'accorder le
// nouveau, sinon le compteur du composant ne redescendrait jamais.
ReleaseActiveStation();
@@ -465,8 +526,22 @@ void AFpsPlayerController::OpenCraftingAtStation(ECraftingStation Station)
Crafting->AddAvailableStation(Station);
ActiveStation = Station;
}
}
SetInventoryScreenOpen(true, /*bShowCraftingTab=*/true);
void AFpsPlayerController::Server_ReleaseStation_Implementation(ECraftingStation Station)
{
if (UCraftingComponent* Crafting = GetPawnCrafting())
{
Crafting->RemoveAvailableStation(Station);
}
}
void AFpsPlayerController::Server_ReleaseStorage_Implementation(AStorageContainer* Container)
{
if (Container)
{
Container->SetOpen(false);
}
}
void AFpsPlayerController::ReleaseActiveStation()
@@ -481,6 +556,14 @@ void AFpsPlayerController::ReleaseActiveStation()
Crafting->RemoveAvailableStation(ActiveStation);
}
// Le serveur tient sa propre liste de postes accessibles, celle qui sert a
// valider Server_Craft. La laisser ouverte rendrait les recettes d'etabli
// fabricables n'importe ou pour le reste de la partie.
if (!HasAuthority())
{
Server_ReleaseStation(ActiveStation);
}
ActiveStation = ECraftingStation::Count;
}
@@ -600,7 +683,22 @@ void AFpsPlayerController::SetUiInputMode(bool bUiOpen)
void AFpsPlayerController::OpenStorage(AStorageContainer* Container)
{
if (!Container || !IsLocalController() || !InventoryScreen)
if (!Container)
{
return;
}
// Appelee depuis Interact_Implementation, donc sur le SERVEUR. Le couvercle
// est de l'etat de monde : il se leve ICI, et se replique aux quatre
// joueurs. Seul l'ecran repart chez le client.
if (!IsLocalController())
{
Container->SetOpen(true);
Client_OpenStorage(Container);
return;
}
if (!InventoryScreen)
{
return;
}
@@ -613,6 +711,10 @@ void AFpsPlayerController::OpenStorage(AStorageContainer* Container)
bStorageOpen = true;
InventoryScreen->ShowStorage(Container);
// SetOpen ne fait rien sans autorite : chez un client c'est la replication
// de bOpen qui animera le couvercle. L'appel reste la pour l'hote, qui est
// bien l'autorite et doit lever le sien tout de suite.
Container->SetOpen(true);
// La barre rapide reste visible et cliquable par-dessus l'ecran : on lui
@@ -634,6 +736,14 @@ void AFpsPlayerController::OpenStorage(AStorageContainer* Container)
StorageDistanceCheckPeriod, /*bLoop=*/true);
}
void AFpsPlayerController::Client_OpenStorage_Implementation(AStorageContainer* Container)
{
// Chez le client IsLocalController() est vrai : OpenStorage prend donc sa
// branche locale et ouvre reellement l'ecran. Le couvercle, lui, a deja ete
// leve par le serveur et arrive par replication de bOpen.
OpenStorage(Container);
}
void AFpsPlayerController::CloseStorage()
{
if (!bStorageOpen)
@@ -659,7 +769,17 @@ void AFpsPlayerController::ReleaseActiveStorage()
if (AStorageContainer* Container = ActiveStorage.Get())
{
Container->SetOpen(false);
// Chez un client SetOpen ne fait rien : le couvercle appartient au
// serveur. On le lui demande, sinon le coffre resterait ouvert pour les
// trois autres joueurs alors que l'ecran est referme ici.
if (HasAuthority())
{
Container->SetOpen(false);
}
else
{
Server_ReleaseStorage(Container);
}
}
ActiveStorage.Reset();
@@ -760,18 +880,18 @@ void AFpsPlayerController::OpenPauseMenu()
bPauseMenuOpen = true;
PauseMenu->SetVisibility(ESlateVisibility::Visible);
// GameAndUI et non UIOnly, meme raison que l'inventaire : en UIOnly plus
// aucune action Enhanced Input ne passe, et Echap ne refermerait rien.
FInputModeGameAndUI Mode;
Mode.SetLockMouseToViewportBehavior(EMouseLockMode::DoNotLock);
Mode.SetHideCursorDuringCapture(false);
SetInputMode(Mode);
SetShowMouseCursor(true);
// Pas de DisableInput sur le pawn ici, contrairement a l'inventaire : le
// monde est reellement fige, et les actions du pawn n'ont pas
// "Trigger When Paused" -- elles ne se declenchent donc plus d'elles-memes.
SetPause(true);
// AUCUN SetPause : en coop la pause est impossible par nature. Cote client
// l'appel est ignore -- la pause appartient a l'autorite -- et cote hote il
// figerait le monde des quatre joueurs parce qu'un seul a ouvert son menu.
// Le monde continue donc de tourner, comme dans Valheim ou Raft : ouvrir son
// menu ne met pas a l'abri, et c'est coherent avec un jeu de survie.
//
// Consequence directe : il faut desormais couper l'input du PAWN, ce que la
// pause faisait gratuitement. C'est exactement le travail de SetUiInputMode,
// deja utilise par l'inventaire et le coffre -- un second chemin finirait
// par diverger, et le symptome (camera qui tourne derriere le menu, sprint
// bloque en sortant) se cherche tres longtemps.
SetUiInputMode(true);
}
void AFpsPlayerController::ResumeGame()
@@ -800,10 +920,65 @@ void AFpsPlayerController::ResumeGame()
PauseMenu->SetVisibility(ESlateVisibility::Collapsed);
}
SetPause(false);
SetUiInputMode(false);
}
SetInputMode(FInputModeGameOnly());
SetShowMouseCursor(false);
FString AFpsPlayerController::ResolveLocalPlayerNickname() const
{
// On passe par l'interface generique, jamais par du Steam explicite : le
// meme code rend le pseudo Steam en ligne et le nom local en LAN.
if (const IOnlineSubsystem* Online = IOnlineSubsystem::Get())
{
const IOnlineIdentityPtr Identity = Online->GetIdentityInterface();
if (Identity.IsValid())
{
const FString Nickname = Identity->GetPlayerNickname(0);
if (!Nickname.IsEmpty())
{
return Nickname;
}
}
}
// Repli sans service en ligne : le nom du LocalPlayer, qui vaut le plus
// souvent "Player". Mieux qu'une etiquette vide.
if (const ULocalPlayer* LocalPlayerRef = GetLocalPlayer())
{
return LocalPlayerRef->GetNickname();
}
return FString();
}
void AFpsPlayerController::Server_SetPlayerNickname_Implementation(const FString& Nickname)
{
APlayerState* State = GetPlayerState<APlayerState>();
if (!State || Nickname.IsEmpty())
{
return;
}
// Borne de longueur : un pseudo Steam monte a 32 caracteres, mais rien
// n'empeche un client modifie d'en envoyer un de dix mille. On tronque
// plutot que de refuser -- refuser laisserait une etiquette vide, tronquer
// donne un nom lisible.
State->SetPlayerName(Nickname.Left(32));
UE_LOG(LogTemp, Log, TEXT("AFpsPlayerController : pseudo [%s]."), *State->GetPlayerName());
}
void AFpsPlayerController::ShowInviteFriends()
{
if (USessionSubsystem* Sessions = GetGameInstance() ? GetGameInstance()->GetSubsystem<USessionSubsystem>() : nullptr)
{
Sessions->ShowInviteUI();
}
}
bool AFpsPlayerController::CanInviteFriends() const
{
const USessionSubsystem* Sessions = GetGameInstance() ? GetGameInstance()->GetSubsystem<USessionSubsystem>() : nullptr;
return Sessions && Sessions->CanInvite();
}
void AFpsPlayerController::OpenSettings()
@@ -957,11 +1132,6 @@ void AFpsPlayerController::HandleFadeFinished()
return;
}
// La pause vit sur les WorldSettings, que OpenLevel detruit avec le monde.
// On la leve quand meme : un monde qu'on quitte en etat de pause est un
// piege si le chargement echoue.
SetPause(false);
UGameplayStatics::OpenLevelBySoftObjectPtr(this, MainMenuLevel);
}