(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
+171 -3
View File
@@ -100,6 +100,9 @@ l'oublie, en Standalone seulement : la caméra ne répond qu'au clic droit maint
**Enhanced Input en pause** : un jeu en pause ne fait qu'un tick d'input réduit, où seules les
actions cochées **« Trigger When Paused »** sont évaluées. C'est une case sur l'asset `IA_`,
pas du C++ — et son corollaire est qu'aucune action du pawn ne se déclenche en pause.
*Le projet ne met plus jamais le jeu en pause (voir le menu de pause plus bas), donc la case sur
`IA_Pause` ne sert plus à rien. La laisser cochée est sans effet ; la note reste ici parce que le
piège ressortira au premier `SetPause` réintroduit.*
**Le `namespace` anonyme n'isole rien.** Le build unity concatène les `.cpp` du module : deux
constantes de même nom dans deux fichiers différents deviennent une redéfinition (C2374). Les
@@ -125,6 +128,166 @@ qu'une compilation propre l'est vraiment.
| Menu de pause | `PauseMenuWidget` + la partie pause de `FpsPlayerController` |
| Réglages | `SurvivalUserSettings`, `SettingsMenuWidget`, `SettingsTabWidget`, `SettingsGameplayWidget`, `SettingRowWidget` |
| Fondus, boîtes de dialogue | `ScreenFadeComponent`, `ConfirmDialogWidget` |
| Coop Steam (sessions) | `SessionSubsystem`, `SurvivalGameMode`, `SurvivalGameState`, `SurvivalPlayerState` |
### Coop Steam — décisions figées
- **Cible : 4 joueurs, serveur d'écoute, P2P par le relais Valve.** Plugins `OnlineSubsystemSteam`
et `SteamSockets`, AppID **4282850** (Emberwild). Le C++ ne parle **jamais** de Steam : tout passe
par `IOnlineSubsystem`, dont l'implémentation est choisie dans `DefaultEngine.ini`. Corollaire
précieux : sans Steam le moteur retombe sur `OnlineSubsystemNULL` et tout le code bascule en LAN
(`USessionSubsystem::IsLanMode`), ce qui rend le test à deux instances possible sur une machine.
- **L'OSS Steam est désactivé de force dans l'éditeur** (`IsEnabled()` teste `IsRunningGame()`).
Steam ne s'initialise donc **jamais en PIE** — c'est voulu par Epic, pas un bug de config. Pour
le tester : **Play > Standalone Game**, ou un build packagé.
- **Toute partie est une partie en ligne.** Un seul bouton *Jouer*, qui crée la session puis ouvre
la map en `?listen`. Pas de « Héberger » vs « Rejoindre » : le joueur devrait choisir avant de
savoir si un ami le rejoindra, et devrait relancer sa partie pour changer d'avis.
- **On rejoint UNIQUEMENT par invitation Steam**, depuis le bouton *Inviter* du menu de pause.
L'acceptation est écoutée par `USessionSubsystem` dès son `Initialize` et jamais coupée : une
invitation tombe à n'importe quel instant, y compris en pleine partie, et le sous-système est le
seul objet dont on soit certain qu'il vive à ce moment-là. La commande console **`EmberJoin`**
existe pour tester à deux instances, sans aucun bouton pour y mener.
- **Un échec de session ne bloque pas le lancement** : la map s'ouvre quand même en serveur
d'écoute, seul *Inviter* est grisé. Un joueur qui clique Jouer veut jouer ; le renvoyer à son
menu pour une panne de Steam serait une punition qui n'est pas la sienne.
- **`bUseLobbiesIfAvailable = true` est LE drapeau à ne pas oublier** : Steam ne route le P2P que
par un lobby. Sans lui la session se crée, apparaît même dans une recherche, mais aucune
connexion n'aboutit hors du LAN.
- **`AGameMode` n'existe que sur le serveur.** `GetGameMode()` rend `nullptr` sur un client,
toujours. Tout appel écrit côté client doit devenir un RPC serveur ou une lecture du GameState —
c'est notamment le cas du respawn dans `AFpsPlayerController`, **encore à faire**.
- **Autorité : le serveur, pour tout ce qui touche au monde.** Pickups, coffres, feux, établis et
inventaires sont modifiés **uniquement** côté serveur ; les clients demandent par RPC et
reçoivent le résultat par réplication. Coop entre amis, donc aucune anti-triche : les
validations serveur sont des garde-fous contre la désynchronisation, pas contre le joueur.
- **`Slots` de `UInventoryComponent` est répliqué à TOUT LE MONDE**, pas au seul propriétaire.
Ce n'est pas un raccourci : les conditions de `GetLifetimeReplicatedProps` sont mises en cache
**par classe**, pas par instance. Un `COND_OwnerOnly` frapperait donc aussi le composant d'un
coffre — or un coffre n'appartient à aucune connexion, et sa grille n'arriverait chez personne.
Le même composant sert le sac et le contenant, et les coffres sont à accès simultané. Coût réel :
4 × 30 slots de trois champs, envoyés seulement quand ils changent.
- **Coffres : accès simultané**, pas de verrou. `TransferSlot` diffuse déjà `OnInventoryChanged`
des deux côtés, l'ossature est là.
### Tester la coop — ça ne se teste QUE en build Steam
**PIE ne teste pas la coop de ce projet.** L'OSS Steam y est désactivé de force par Epic
(`IsEnabled()` teste `IsRunningGame()`), donc pas de session, pas d'invitation, pas de relais.
PIE en *Play As Listen Server* reste utile pour la réplication pure (voir l'autre joueur bouger,
un pickup disparaître), mais **tout ce qui touche à la session se teste en build packagé**,
distribué par le dépôt Steam. C'est la procédure de référence.
**1. Packager** — éditeur fermé :
```powershell
& "C:\Program Files\Epic Games\UE_5.8\Engine\Build\BatchFiles\RunUAT.bat" BuildCookRun `
-project="C:\Users\Mathew\Documents\Perso\Unreal\Unreal_EmberWild\Survival_projet.uproject" `
-noP4 -platform=Win64 -clientconfig=Development `
-build -cook -stage -pak -compressed -prereqs `
-archive -archivedirectory="...\Build\Package" -utf8output -nocompileeditor
```
`Development` et pas `Shipping` : on garde la console (`~`), les logs dans `Saved/Logs` et la
commande `EmberJoin`. Sans eux, un échec chez le testeur ne dit rien. Premier cook ≈ 18 min,
les suivants sont bien plus courts (DDC chaud).
**2. Copier dans le dépôt** — le contenu de `Build\Package\Windows\` va **à la racine** de
`C:\sdk\tools\ContentBuilder\content\`, en excluant `steam_appid.txt` : Steam fournit lui-même
l'AppID au lancement, et le fichier l'écraserait.
**3. Uploader**`C:\sdk\tools\ContentBuilder\upload_emberwild.bat` (App **4282850**, dépôt
**4282851**). Le `.vdf` n'a **pas** de `SetLive`, donc le build monte sans devenir live.
**4. Publier sur la branche `develop`**, jamais sur `default`. Et vérifier que *Launch Options*
pointe sur `Survival_projet.exe` — puis **publier les changements**, sinon rien n'est visible
côté client.
**5. Tester à deux** : les deux lancent le jeu **depuis Steam**, l'hôte clique *Jouer*, puis
Échap → *Inviter des amis*. Les logs à lire sont dans `Survival_projet/Saved/Logs/` de chaque
côté — `ShowInviteUI` chez l'hôte, `invitation acceptee` puis `voyage vers [...]` chez l'invité.
**Deux réglages qui cassent tout si on les oublie** : la *List of Maps to Include* dans les
Packaging Settings doit contenir `MenuScene` **et** `GameScene` — cette dernière n'est référencée
que par un `TSoftObjectPtr`, et une référence soft n'est pas suivie par le cooker ; sans elle le
build démarre sur le menu et *Jouer* ne charge rien. Et sans liste du tout, le cook embarque les
~25 maps de démo SoStylized.
**Se tester seul** reste possible malgré l'invitation obligatoire : deux instances lancées avec
`-nosteam` retombent sur `OnlineSubsystemNULL`, donc en LAN, et la commande console `EmberJoin`
fait recherche + connexion sur la seconde.
### Réplication — la règle, et où on en est
**La règle : rien de ce qui touche au monde ne s'exécute sur un client.** Un client trace, vise,
affiche, glisse — mais dès qu'il faut *changer* quelque chose, il envoie un RPC serveur et attend
la réplication. Le symptôme quand on l'oublie est toujours le même et il est fourbe : ça marche
parfaitement en solo et chez l'hôte, et l'objet réapparaît chez le client à la réplication suivante.
**Le point de passage unique : `IInteractable::Execute_Interact`.** Les quatre acteurs du monde
(`PickupItem`, `CookingStation`, `CraftingStation`, `StorageContainer`) passent tous par là.
`UInteractionComponent::TryInteract()` route donc vers `Server_Interact` quand on n'a pas
l'autorité — une seule RPC rend les quatre autoritaires. Ne jamais appeler `Execute_Interact`
ailleurs sans se poser la question de qui exécute.
**`SetIsReplicatedByDefault(true)` est obligatoire sur un composant qui porte un RPC serveur**,
même s'il ne réplique aucune propriété : sans lui le moteur **jette silencieusement** l'appel.
**`OnConstruction` s'exécute avant l'arrivée des propriétés répliquées.** Un acteur dont le
visuel dépend d'une `UPROPERTY` répliquée doit la déclarer `ReplicatedUsing` et reposer son
visuel dans l'`OnRep` — sinon l'objet est correct chez l'hôte et nu chez les clients. C'est
exactement le cas d'`APickupItem::OnRep_ItemData`.
**L'UI se recâble toute seule.** Tous les widgets sont abonnés à `OnInventoryChanged` et aucun
ne fait de Tick : il suffit qu'`OnRep_Slots()` rediffuse ce délégué. C'est le dividende de la
règle « les widgets s'abonnent à des délégués », et il faut la tenir pour les systèmes à venir.
| Système | État |
|---|---|
| Déplacement, crouch | ✅ gratuit via `ACharacter` / `CharacterMovement` |
| Sprint (`bIsSprinting` + RPC vitesse) | ✅ fait |
| Interaction (RPC serveur + validation de distance) | ✅ fait |
| `PickupItem` (ramassage, visuel, quantité) | ✅ fait |
| `InventoryComponent` — lecture (`Slots` répliqué) | ✅ fait |
| `InventoryComponent` — écriture (transferts, consommation) | ✅ fait |
| Jeter au sol (`DropFromInventory`, `DropAllItems`) | ✅ fait |
| `SurvivalStatsComponent` (simulation serveur, mort par `OnRep_Dead`) | ✅ fait |
| Respawn (`Server_RequestRespawn`) | ✅ fait |
| `StorageContainer` (contenu, couvercle) | ✅ fait |
| `CraftingComponent` (`Server_Craft`) | ✅ fait |
| `CookingStation` (grill, bûches, flamme) | ✅ fait |
**Un acteur du monde qui ouvre une interface doit RENVOYER l'ordre au client.**
`Interact_Implementation` s'exécute sur le serveur : `PlayerController->OpenStorage(...)` y créerait
un widget dans le vide et l'écran du joueur ne s'ouvrirait jamais. D'où `Client_OpenStorage` et
`Client_OpenCraftingAtStation`. C'est le piège le plus contre-intuitif du portage, parce que le
code *semble* correct et fonctionne parfaitement chez l'hôte.
**L'établi n'a rien à répliquer.** `ACraftingStation` est un meuble : un mesh, un prompt, aucun
état. Ce qui devait changer, c'est le sens de l'appel (ci-dessus) et `Craft()`, qui touche
l'inventaire autoritaire. Le poste accessible est tenu **des deux côtés** — le client en a besoin
pour filtrer sa grille de recettes, le serveur pour valider `Server_Craft`. N'en tenir qu'un
donnerait soit un onglet vide devant un établi, soit un établi utilisable depuis la forêt.
**La cuisson ne se compte que sur le serveur.** Les `FTimerHandle` ne sont pas des `UPROPERTY` et
ne partent jamais sur le réseau : les clients ne reçoivent que le *résultat* — l'objet devenu
cuit, la bûche disparue, la flamme allumée. Quatre clients qui compteraient chacun leur cuisson
divergeraient, et le steak serait brûlé chez l'un et cru chez l'autre.
**Le sprint est le piège qui ne se voit pas chez l'hôte.** `MaxWalkSpeed` posé en local
seulement laisse le serveur croire le client à `WalkSpeed` : il corrige alors sa position à
chaque pas, et c'est le rubber-banding. D'où `Server_SetSprinting`, appelée **après** avoir
appliqué en local — l'inverse donnerait un sprint qui démarre en retard.
**Pas de prédiction sur l'inventaire.** Le client envoie sa RPC et attend `OnRep_Slots`.
Quelques dizaines de millisecondes sur un relais Steam, invisibles pour un glisser-déposer,
alors qu'une prédiction fausse donnerait une pile qui saute.
**`FindNetProxy` est le passage obligé des transferts vers un coffre.** Un `UInventoryComponent`
de conteneur n'appartient à aucune connexion : une RPC émise depuis lui est **silencieusement
jetée**. On route donc toujours par l'inventaire du pawn local, et la source comme la destination
sont passées en paramètres — jamais déduites du récepteur, sinon « Tout prendre » viderait le sac
du joueur dans lui-même.
### Décisions de gameplay figées
@@ -148,9 +311,14 @@ qu'une compilation propre l'est vraiment.
**relit** à son `BeginPlay` et s'abonne à `OnSurvivalSettingsApplied`.
- **Échap** a une priorité explicite dans `HandlePauseInput()` : boîte de confirmation →
réglages → inventaire → pause. Sans cet ordre, deux écrans se retrouvent ouverts en même temps.
- **Menu de pause** : `SetPause(true)` fige le monde, mais **pas Slate** — les animations UMG et
`ScreenFadeComponent` continuent (d'où son `bTickEvenWhenPaused`). Pas de bouton « Quitter le
jeu » : la sortie se fait depuis le menu principal.
- **Menu de pause : il ne met plus rien en pause.** `SetPause()` a disparu du projet. En coop la
pause est impossible par nature — côté client l'appel est ignoré (elle appartient à l'autorité),
côté hôte elle figerait le monde des quatre joueurs parce qu'un seul a ouvert son menu. Le monde
continue donc de tourner, comme dans Valheim et Raft : ouvrir son menu ne met pas à l'abri.
Corollaire **non négociable** : c'est la pause qui coupait gratuitement l'input du pawn, il faut
donc passer par `SetUiInputMode(true)` — le même que l'inventaire et le coffre. L'oublier laisse
la caméra tourner derrière le menu. Pas de bouton « Quitter le jeu » : la sortie se fait depuis
le menu principal.
- **Réglages** : `USurvivalUserSettings` fait autorité, jamais le CDO du pawn. Application
immédiate, aucun bouton « Appliquer » ; la sauvegarde a lieu à la fermeture de l'écran.
L'écran ignore qui l'ouvre (`OnCloseRequested`), d'où un seul `WBP_Settings` pour le menu
+40
View File
@@ -92,6 +92,46 @@ FontDPI=72
; n'existent tout simplement pas au runtime.
GameUserSettingsClassName=/Script/Survival_projet.SurvivalUserSettings
; ==================================================================
; Coop Steam (4 joueurs, listen server, P2P par le relais Valve)
; ==================================================================
; Le NetDriver par defaut ouvre un socket UDP et attend une IP : inutilisable
; derriere un NAT. SteamSocketsNetDriver fait transiter la connexion par le
; relais de Valve -- aucun port a ouvrir, aucune IP echangee entre joueurs.
; Le ClearArray est obligatoire : sans lui on empile une seconde definition
; "GameNetDriver" et c'est celle du moteur qui gagne.
; Le Fallback sert au PIE et aux tests hors Steam : sans client Steam lance,
; le moteur retombe sur l'IpNetDriver classique au lieu de refuser de demarrer.
[/Script/Engine.GameEngine]
!NetDriverDefinitions=ClearArray
+NetDriverDefinitions=(DefName="GameNetDriver",DriverClassName="/Script/SteamSockets.SteamSocketsNetDriver",DriverClassNameFallback="/Script/OnlineSubsystemUtils.IpNetDriver")
[/Script/SteamSockets.SteamSocketsNetDriver]
NetConnectionClassName="/Script/SteamSockets.SteamSocketsNetConnection"
[OnlineSubsystem]
DefaultPlatformService=Steam
; SteamDevAppId = notre AppID reel (Emberwild). Le classique 480 (Spacewar)
; ne sert que si des testeurs ne possedent pas encore le jeu sur leur compte.
;
; PAS de bInitServerOnClient : il force l'initialisation de l'API GameServer
; de Steam en plus de l'API client, dans le meme process. C'est fait pour
; tester du code de serveur dedie sur une machine de dev. Un listen server
; n'en a pas besoin -- il cree un LOBBY Steam via l'API client. Pire, si
; l'init serveur echoue alors que le client a reussi, OnlineSubsystemSteam
; detruit TOUT (voir le Shutdown() de son Init) : on ajouterait un mode
; d'echec sans rien gagner.
;
; Rappel qui fait perdre du temps : dans l'EDITEUR, IsEnabled() rend toujours
; faux (IsRunningGame() est faux), donc Steam ne s'initialise jamais en PIE et
; le moteur retombe sur OnlineSubsystemNULL. C'est voulu par Epic. Pour tester
; Steam il faut Play > Standalone Game, ou un build package.
[OnlineSubsystemSteam]
bEnabled=true
SteamDevAppId=4282850
[/Script/AndroidFileServerEditor.AndroidFileServerRuntimeSettings]
bEnablePlugin=True
bAllowNetworkConnection=True
+12
View File
@@ -13,3 +13,15 @@ ProjectID=4774BE68499C24D0A70C6E87B49DA419
[/Script/DiscordRichPresence.DiscordRichPresenceSettings]
ClientId=1432045921620725933
; Sans cette liste le cooker embarque TOUTES les maps du dossier Content, donc
; les ~25 maps de demonstration de SoStylized : build enorme et cook interminable.
;
; MenuScene doit y figurer meme si c'est deja la GameDefaultMap, et GameScene
; SURTOUT : elle n'est referencee que par un TSoftObjectPtr (GameLevel sur
; BP_MainMenuPlayerController). Une reference soft n'est pas suivie par le
; cooker comme une reference dure -- sans cette ligne le build demarre sur le
; menu, on clique Jouer, et rien ne se charge.
[/Script/UnrealEd.ProjectPackagingSettings]
+MapsToCook=(FilePath="/Game/Game/Maps/MenuScene")
+MapsToCook=(FilePath="/Game/Game/Maps/GameScene")
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -11,6 +11,7 @@
#include "ItemDataAsset.h"
#include "Kismet/GameplayStatics.h"
#include "NiagaraComponent.h"
#include "Net/UnrealNetwork.h"
#include "TimerManager.h"
#define LOCTEXT_NAMESPACE "CookingStation"
@@ -20,6 +21,14 @@ ACookingStation::ACookingStation()
// Tout passe par des timers : la station n'a rien a faire chaque frame.
PrimaryActorTick.bCanEverTick = false;
// Acteur du monde. La cuisson et la combustion ne sont comptees QUE par le
// serveur : les timers ne partent jamais sur le reseau, seuls leurs
// resultats -- l'objet devenu cuit, la buche disparue -- sont repliques.
// Quatre clients qui compteraient chacun leur cuisson divergeraient, et le
// steak serait brule chez l'un et cru chez l'autre.
bReplicates = true;
SetReplicateMovement(false);
BaseMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("BaseMesh"));
SetRootComponent(BaseMesh);
// Le foyer est un obstacle physique, contrairement a un ramassable : on
@@ -77,22 +86,51 @@ ACookingStation::ACookingStation()
}
}
void ACookingStation::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(ACookingStation, Slots);
DOREPLIFETIME(ACookingStation, Fuel);
DOREPLIFETIME(ACookingStation, bIsLit);
}
void ACookingStation::OnRep_Slots()
{
// Le mesh d'un emplacement suit son contenu : c'est TOUT l'affichage de la
// cuisson, il n'y a aucune interface. Cru, cuit, brule -- le joueur ne lit
// que ca.
for (int32 Index = 0; Index < Slots.Num(); ++Index)
{
RefreshSlotMesh(Index);
}
}
void ACookingStation::OnRep_Fuel()
{
RefreshFuelIndicators();
}
void ACookingStation::OnRep_IsLit()
{
ApplyFireVisuals(bIsLit);
}
void ACookingStation::BeginPlay()
{
Super::BeginPlay();
Slots.SetNum(MaxCookingSlots);
// Les meshes de reperage poses dans le Blueprint n'ont plus rien a faire la :
// RefreshSlotMesh remet chaque emplacement sur son contenu reel, donc rien.
for (int32 Index = 0; Index < MaxCookingSlots; ++Index)
{
RefreshSlotMesh(Index);
}
// Avant tout usage du carburant : c'est ce recensement qui fixe la capacite.
// Recensement des buches AVANT tout : il fixe la capacite, et il est purement
// local -- ce sont des composants du Blueprint, identiques partout.
CacheFuelIndicators();
// Le contenu initial est de l'etat de monde : seul le serveur le pose, les
// clients le recevront par replication. Sans cette garde, chaque client se
// donnerait ses propres buches de depart en plus de celles du serveur.
if (HasAuthority())
{
Slots.SetNum(MaxCookingSlots);
if (StartingFuelItem && StartingFuelItem->bIsFuel)
{
const int32 Count = FMath::Min(StartingFuelCount, GetFuelCapacity());
@@ -103,6 +141,16 @@ void ACookingStation::BeginPlay()
NewFuel.RemainingSeconds = FMath::Max(1.f, StartingFuelItem->FuelDuration);
}
}
}
// Les meshes de reperage poses dans le Blueprint n'ont plus rien a faire la :
// RefreshSlotMesh remet chaque emplacement sur son contenu reel, donc rien.
// Chez un client Slots est encore vide a cet instant ; OnRep_Slots repassera
// des que le contenu reel arrivera.
for (int32 Index = 0; Index < Slots.Num(); ++Index)
{
RefreshSlotMesh(Index);
}
RefreshFuelIndicators();
@@ -110,7 +158,11 @@ void ACookingStation::BeginPlay()
// arrive. On passe quand meme par le point de decision unique plutot que
// d'ecrire un cas particulier de plus.
ApplyFireVisuals(false);
if (HasAuthority())
{
UpdateFireState();
}
}
void ACookingStation::EndPlay(const EEndPlayReason::Type EndPlayReason)
@@ -245,6 +297,14 @@ bool ACookingStation::HasPendingWork() const
void ACookingStation::UpdateFireState()
{
// Point de decision unique du feu, donc point de garde unique : bIsLit et
// les timers de combustion appartiennent au serveur. Chez un client c'est
// OnRep_IsLit qui allume la flamme, sans jamais rien decider.
if (!HasAuthority())
{
return;
}
// Un objet DEJA cuit qui peut encore bruler compte comme du travail : sans
// ca le feu s'arreterait pile a la fin de la cuisson et la sur-cuisson
// n'arriverait jamais. Oublier sa viande doit rester punitif.
@@ -0,0 +1,892 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "CookingStation.h"
#include "Components/PointLightComponent.h"
#include "Components/SphereComponent.h"
#include "Components/StaticMeshComponent.h"
#include "Engine/World.h"
#include "InventoryComponent.h"
#include "ItemDataAsset.h"
#include "Kismet/GameplayStatics.h"
#include "NiagaraComponent.h"
#include "Net/UnrealNetwork.h"
#include "TimerManager.h"
#define LOCTEXT_NAMESPACE "CookingStation"
ACookingStation::ACookingStation()
{
// Tout passe par des timers : la station n'a rien a faire chaque frame.
PrimaryActorTick.bCanEverTick = false;
// Acteur du monde. La cuisson et la combustion ne sont comptees QUE par le
// serveur : les timers ne partent jamais sur le reseau, seuls leurs
// resultats -- l'objet devenu cuit, la buche disparue -- sont repliques.
// Quatre clients qui compteraient chacun leur cuisson divergeraient, et le
// steak serait brule chez l'un et cru chez l'autre.
bReplicates = true;
SetReplicateMovement(false);
BaseMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("BaseMesh"));
SetRootComponent(BaseMesh);
// Le foyer est un obstacle physique, contrairement a un ramassable : on
// n'entre pas dans un feu de camp.
BaseMesh->SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics);
InteractionSphere = CreateDefaultSubobject<USphereComponent>(TEXT("InteractionSphere"));
InteractionSphere->SetupAttachment(BaseMesh);
InteractionSphere->InitSphereRadius(90.f);
InteractionSphere->SetCollisionEnabled(ECollisionEnabled::QueryOnly);
InteractionSphere->SetCollisionResponseToAllChannels(ECR_Ignore);
InteractionSphere->SetCollisionResponseToChannel(ECC_Visibility, ECR_Block);
FireEffect = CreateDefaultSubobject<UNiagaraComponent>(TEXT("FireEffect"));
FireEffect->SetupAttachment(BaseMesh);
// Surtout pas d'auto-activation : un feu neuf n'a pas de bois, il ne doit
// pas flamber une frame avant que BeginPlay ne le coupe.
FireEffect->SetAutoActivate(false);
FireLight = CreateDefaultSubobject<UPointLightComponent>(TEXT("FireLight"));
FireLight->SetupAttachment(BaseMesh);
FireLight->SetRelativeLocation(FVector(0.f, 0.f, 40.f));
// Movable, sinon rien ne fonctionne : une lumiere Static -- le defaut d'un
// composant lumiere -- est cuite dans le lightmap, et l'allumer ou
// l'eteindre a l'execution n'a strictement aucun effet a l'ecran.
FireLight->SetMobility(EComponentMobility::Movable);
FireLight->SetIntensity(3000.f);
FireLight->SetAttenuationRadius(600.f);
FireLight->SetLightColor(FLinearColor(1.f, 0.55f, 0.2f));
// Une lumiere ponctuelle projette ses ombres sur SIX faces de cubemap, a
// chaque frame et pour chaque objet a portee. Sur un feu de camp qu'on
// posera par dizaines, c'est le poste le plus cher du systeme, et de loin.
// Coche-la sur le feu principal du campement si tu la veux, pas partout.
FireLight->CastShadows = false;
FireLight->SetVisibility(false);
SlotMeshes.Reserve(MaxCookingSlots);
for (int32 Index = 0; Index < MaxCookingSlots; ++Index)
{
const FName ComponentName(*FString::Printf(TEXT("CookSlot_%d"), Index));
UStaticMeshComponent* SlotMesh = CreateDefaultSubobject<UStaticMeshComponent>(ComponentName);
SlotMesh->SetupAttachment(BaseMesh);
SlotMesh->SetCollisionEnabled(ECollisionEnabled::NoCollision);
// Visible dans l'editeur pour que tu puisses positionner l'emplacement a
// la souris avec un mesh de reperage ; le jeu le cache au demarrage tant
// qu'il n'y a rien dessus.
SlotMesh->SetHiddenInGame(false);
SlotMeshes.Add(SlotMesh);
}
}
void ACookingStation::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(ACookingStation, Slots);
DOREPLIFETIME(ACookingStation, Fuel);
DOREPLIFETIME(ACookingStation, bIsLit);
}
void ACookingStation::OnRep_Slots()
{
// Le mesh d'un emplacement suit son contenu : c'est TOUT l'affichage de la
// cuisson, il n'y a aucune interface. Cru, cuit, brule -- le joueur ne lit
// que ca.
for (int32 Index = 0; Index < Slots.Num(); ++Index)
{
RefreshSlotMesh(Index);
}
}
void ACookingStation::OnRep_Fuel()
{
RefreshFuelIndicators();
}
void ACookingStation::OnRep_IsLit()
{
ApplyFireVisuals(bIsLit);
}
void ACookingStation::BeginPlay()
{
Super::BeginPlay();
// Recensement des buches AVANT tout : il fixe la capacite, et il est purement
// local -- ce sont des composants du Blueprint, identiques partout.
CacheFuelIndicators();
// Le contenu initial est de l'etat de monde : seul le serveur le pose, les
// clients le recevront par replication. Sans cette garde, chaque client se
// donnerait ses propres buches de depart en plus de celles du serveur.
if (HasAuthority())
{
Slots.SetNum(MaxCookingSlots);
if (StartingFuelItem && StartingFuelItem->bIsFuel)
{
const int32 Count = FMath::Min(StartingFuelCount, GetFuelCapacity());
for (int32 Index = 0; Index < Count; ++Index)
{
FFuelSlotState& NewFuel = Fuel.AddDefaulted_GetRef();
NewFuel.Item = StartingFuelItem;
NewFuel.RemainingSeconds = FMath::Max(1.f, StartingFuelItem->FuelDuration);
}
}
}
// Les meshes de reperage poses dans le Blueprint n'ont plus rien a faire la :
// RefreshSlotMesh remet chaque emplacement sur son contenu reel, donc rien.
// Chez un client Slots est encore vide a cet instant ; OnRep_Slots repassera
// des que le contenu reel arrivera.
for (int32 Index = 0; Index < Slots.Num(); ++Index)
{
RefreshSlotMesh(Index);
}
RefreshFuelIndicators();
// Le grill est vide au demarrage, donc le feu reste eteint quoi qu'il
// arrive. On passe quand meme par le point de decision unique plutot que
// d'ecrire un cas particulier de plus.
ApplyFireVisuals(false);
if (HasAuthority())
{
UpdateFireState();
}
}
void ACookingStation::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
// Les timers portent un delegue vers this. Les laisser tourner apres la
// destruction de l'acteur est le genre de detail qui ne se voit qu'au
// changement de niveau, sous la forme d'un crash sans pile lisible.
if (UWorld* World = GetWorld())
{
FTimerManager& TimerManager = World->GetTimerManager();
TimerManager.ClearTimer(FuelTimer);
for (FCookingSlotState& Slot : Slots)
{
TimerManager.ClearTimer(Slot.Timer);
}
}
Super::EndPlay(EndPlayReason);
}
// ----------------------------------------------------------------------
// Carburant et allumage
// ----------------------------------------------------------------------
float ACookingStation::GetFuelRemaining() const
{
float Total = 0.f;
for (int32 Index = 0; Index < Fuel.Num(); ++Index)
{
// Feu allume, c'est le timer qui fait autorite pour la buche en cours :
// son RemainingSeconds n'est rafraichi qu'a l'extinction.
if (Index == 0 && bIsLit)
{
Total += FMath::Max(0.f, GetWorldTimerManager().GetTimerRemaining(FuelTimer));
}
else
{
Total += Fuel[Index].RemainingSeconds;
}
}
return Total;
}
void ACookingStation::SyncFuelFromTimer()
{
if (bIsLit && Fuel.Num() > 0)
{
Fuel[0].RemainingSeconds = FMath::Max(0.f, GetWorldTimerManager().GetTimerRemaining(FuelTimer));
}
}
void ACookingStation::StartFuelTimer()
{
GetWorldTimerManager().ClearTimer(FuelTimer);
if (!bIsLit || Fuel.Num() == 0)
{
return;
}
const float Duration = FMath::Max(0.05f, Fuel[0].RemainingSeconds);
GetWorldTimerManager().SetTimer(FuelTimer, this, &ACookingStation::HandleFuelConsumed, Duration, false);
}
bool ACookingStation::Light()
{
if (bIsLit)
{
return true;
}
if (Fuel.Num() == 0)
{
return false;
}
bIsLit = true;
StartFuelTimer();
// Reprise, pas redemarrage : chaque cuisson repart exactement la ou elle
// s'etait figee quand le feu s'est eteint.
for (const FCookingSlotState& Slot : Slots)
{
GetWorldTimerManager().UnPauseTimer(Slot.Timer);
}
ApplyFireVisuals(true);
UGameplayStatics::PlaySoundAtLocation(this, LightSound, GetActorLocation());
OnStationChanged.Broadcast();
return true;
}
void ACookingStation::Extinguish()
{
if (!bIsLit)
{
return;
}
SyncFuelFromTimer();
GetWorldTimerManager().ClearTimer(FuelTimer);
bIsLit = false;
for (const FCookingSlotState& Slot : Slots)
{
GetWorldTimerManager().PauseTimer(Slot.Timer);
}
ApplyFireVisuals(false);
UGameplayStatics::PlaySoundAtLocation(this, ExtinguishSound, GetActorLocation());
OnStationChanged.Broadcast();
}
bool ACookingStation::HasPendingWork() const
{
for (const FCookingSlotState& Slot : Slots)
{
float Unused = 0.f;
if (!Slot.IsEmpty() && GetNextStage(Slot.Item, Unused))
{
return true;
}
}
return false;
}
void ACookingStation::UpdateFireState()
{
// Un objet DEJA cuit qui peut encore bruler compte comme du travail : sans
// ca le feu s'arreterait pile a la fin de la cuisson et la sur-cuisson
// n'arriverait jamais. Oublier sa viande doit rester punitif.
if (Fuel.Num() > 0 && HasPendingWork())
{
Light();
}
else
{
Extinguish();
}
}
void ACookingStation::HandleFuelConsumed()
{
if (Fuel.Num() > 0)
{
// RemoveAt et non un index de lecture qui avance : le foyer doit
// toujours se lire "les N premieres buches sont pleines", c'est ce qui
// rend l'affichage trivial et sans etat supplementaire.
Fuel.RemoveAt(0);
}
RefreshFuelIndicators();
if (Fuel.Num() == 0)
{
Extinguish();
return;
}
StartFuelTimer();
OnStationChanged.Broadcast();
}
int32 ACookingStation::FindFuelSource(const UInventoryComponent* Inventory, UItemDataAsset*& OutItem)
{
OutItem = nullptr;
if (!Inventory)
{
return INDEX_NONE;
}
// La main d'abord : c'est le seul moyen pour le joueur de designer ce qu'il
// accepte de bruler. Sans cette priorite, remplir le feu piocherait la
// premiere chose venue et ses planches partiraient avant ses branches.
const FInventorySlot Selected = Inventory->GetSelectedSlot();
if (Selected.Item && Selected.Item->bIsFuel)
{
OutItem = Selected.Item;
return Inventory->GetSelectedHotbarIndex();
}
const TArray<FInventorySlot>& InventorySlots = Inventory->GetSlots();
for (int32 Index = 0; Index < InventorySlots.Num(); ++Index)
{
if (!InventorySlots[Index].IsEmpty() && InventorySlots[Index].Item->bIsFuel)
{
OutItem = InventorySlots[Index].Item;
return Index;
}
}
return INDEX_NONE;
}
bool ACookingStation::AddFuelFrom(int32 InventorySlotIndex, AActor* Interactor)
{
UInventoryComponent* Inventory = GetInventoryOf(Interactor);
if (!Inventory || Fuel.Num() >= GetFuelCapacity())
{
return false;
}
// Un seul exemplaire, jamais la pile : c'est le joueur qui decide combien
// de bois il engage, buche par buche.
FInventorySlot Taken;
if (!Inventory->TakeFromSlot(InventorySlotIndex, 1, Taken) || Taken.IsEmpty())
{
return false;
}
FFuelSlotState& NewFuel = Fuel.AddDefaulted_GetRef();
NewFuel.Item = Taken.Item;
NewFuel.RemainingSeconds = FMath::Max(1.f, Taken.Item->FuelDuration);
RefreshFuelIndicators();
// Du bois arrive alors qu'une cuisson attendait : c'est ce qui relance le
// feu. Si rien n'attend, la buche patiente sans se consumer.
UpdateFireState();
UGameplayStatics::PlaySoundAtLocation(this, PlaceItemSound, GetActorLocation());
OnStationChanged.Broadcast();
return true;
}
void ACookingStation::ApplyFireVisuals(bool bActive)
{
if (bActive)
{
FireEffect->Activate(/*bReset=*/true);
}
else
{
FireEffect->Deactivate();
}
FireLight->SetVisibility(bActive);
}
void ACookingStation::CacheFuelIndicators()
{
TArray<UStaticMeshComponent*> Found;
GetComponents<UStaticMeshComponent>(Found);
FuelIndicators.Reset();
for (UStaticMeshComponent* Component : Found)
{
if (Component->ComponentHasTag(FuelIndicatorTag))
{
FuelIndicators.Add(Component);
}
}
// GetComponents ne promet aucun ordre. Sans tri, la buche qui disparait en
// premier changerait d'une compilation du Blueprint a l'autre -- un bug
// qu'on ne reproduit jamais deux fois pareil.
//
// TArray::Sort DEREFERENCE les elements des qu'il s'agit de pointeurs,
// TObjectPtr compris : le predicat recoit les composants, pas les pointeurs.
//
// Tri alphabetique, donc Log_10 passerait AVANT Log_2 : au-dela de neuf
// buches, nomme-les Log_01, Log_02...
FuelIndicators.Sort([](const UStaticMeshComponent& A, const UStaticMeshComponent& B)
{
return A.GetName().Compare(B.GetName()) < 0;
});
}
void ACookingStation::RefreshFuelIndicators()
{
for (int32 Index = 0; Index < FuelIndicators.Num(); ++Index)
{
const bool bOccupied = Fuel.IsValidIndex(Index);
FuelIndicators[Index]->SetVisibility(bOccupied);
if (bOccupied && bFuelIndicatorsUseItemMesh && Fuel[Index].Item)
{
Fuel[Index].Item->ApplyWorldVisualsTo(FuelIndicators[Index]);
}
}
}
// ----------------------------------------------------------------------
// Cuisson
// ----------------------------------------------------------------------
UItemDataAsset* ACookingStation::GetNextStage(const UItemDataAsset* Item, float& OutDuration)
{
OutDuration = 0.f;
if (!Item)
{
return nullptr;
}
// Le test "!= Item" n'est pas de la paranoia : un resultat qui pointe sur
// l'objet lui-meme fait tourner le timer indefiniment sans que rien ne
// change a l'ecran. La viande "cuit" en boucle, reste crue, et le feu ne
// s'arrete jamais puisqu'il croit avoir du travail. Vu en jeu, ca ressemble
// a un timer casse, et on cherche du cote du code pendant une heure.
//
// La cuisson d'abord : un objet cru qui porterait aussi bCanBurn doit
// devenir cuit avant de se gacher, jamais l'inverse.
if (Item->bCanBeCooked && Item->CookedResult && Item->CookedResult != Item)
{
OutDuration = Item->CookDuration;
return Item->CookedResult;
}
if (Item->bCanBurn && Item->BurntResult && Item->BurntResult != Item)
{
OutDuration = Item->BurnDuration;
return Item->BurntResult;
}
return nullptr;
}
void ACookingStation::StartSlotTimer(int32 SlotIndex)
{
FCookingSlotState& Slot = Slots[SlotIndex];
GetWorldTimerManager().ClearTimer(Slot.Timer);
if (Slot.IsEmpty())
{
return;
}
float Duration = 0.f;
if (!GetNextStage(Slot.Item, Duration))
{
// Un objet qui se DIT transformable sans avoir d'etape suivante
// exploitable n'est pas une fin de chaine, c'est une donnee cassee.
// Sans ce message il resterait inerte sur le grill, et rien a l'ecran
// ne dirait que le probleme est dans l'asset et pas dans le feu.
if (Slot.Item->bCanBeCooked || Slot.Item->bCanBurn)
{
UE_LOG(LogTemp, Warning, TEXT("ACookingStation : %s se declare transformable mais n'a aucune etape suivante valide (resultat vide, ou pointant sur lui-meme). Il restera inerte sur le feu."),
*GetNameSafe(Slot.Item));
}
// Maillon final -- un steak brule -- : il reste sur le grill sans plus
// rien faire, et sans consommer un timer pour ne rien faire.
return;
}
Duration = FMath::Max(0.05f, Duration / CookSpeedMultiplier);
const FTimerDelegate Finished = FTimerDelegate::CreateUObject(this, &ACookingStation::HandleSlotFinished, SlotIndex);
GetWorldTimerManager().SetTimer(Slot.Timer, Finished, Duration, false);
// Poser quelque chose sur un feu eteint est autorise : le compte a rebours
// existe, il attend simplement qu'on allume.
if (!bIsLit)
{
GetWorldTimerManager().PauseTimer(Slot.Timer);
}
}
void ACookingStation::HandleSlotFinished(int32 SlotIndex)
{
if (!Slots.IsValidIndex(SlotIndex))
{
return;
}
FCookingSlotState& Slot = Slots[SlotIndex];
float Unused = 0.f;
UItemDataAsset* NextItem = GetNextStage(Slot.Item, Unused);
if (!NextItem)
{
return;
}
Slot.Item = NextItem;
RefreshSlotMesh(SlotIndex);
// Enchainement automatique : le steak cuit qui porte bCanBurn se remet a
// compter tout seul vers le brule. C'est ce qui fait que le joueur doit
// revenir chercher sa viande au lieu de l'oublier sur le feu.
StartSlotTimer(SlotIndex);
// Apres StartSlotTimer : si ce maillon etait le dernier, il n'y a plus rien
// a transformer et le feu s'arrete de lui-meme.
UpdateFireState();
OnStationChanged.Broadcast();
}
float ACookingStation::GetSlotTimeRemaining(int32 SlotIndex) const
{
if (!Slots.IsValidIndex(SlotIndex))
{
return 0.f;
}
// GetTimerRemaining renvoie -1 quand le handle ne designe aucun timer actif.
return FMath::Max(0.f, GetWorldTimerManager().GetTimerRemaining(Slots[SlotIndex].Timer));
}
bool ACookingStation::CanTakeFromSlot(int32 SlotIndex) const
{
if (!Slots.IsValidIndex(SlotIndex) || Slots[SlotIndex].IsEmpty())
{
return false;
}
if (bAllowTakingRawItems)
{
return true;
}
const UItemDataAsset* Item = Slots[SlotIndex].Item;
// Critere porte par l'objet lui-meme, sans etat a maintenir : tant qu'il
// sait encore CUIRE, il est cru, donc engage. Des qu'il ne sait plus que
// bruler, c'est qu'il a fini sa cuisson et qu'on peut le sauver.
//
// Consequence voulue : reposer une viande cuite sur le feu ne la reverrouille
// pas, alors qu'un booleen "a deja transforme" l'aurait fait.
if (!Item->bCanBeCooked)
{
return true;
}
// Second filet : un objet dont la chaine est cassee ne cuira jamais. Le
// laisser verrouille le sequestrerait pour toujours sur le grill, avec pour
// seul recours de recharger la partie.
float Unused = 0.f;
return GetNextStage(Item, Unused) == nullptr;
}
void ACookingStation::RefreshSlotMesh(int32 SlotIndex)
{
UStaticMeshComponent* SlotMesh = SlotMeshes[SlotIndex];
const FCookingSlotState& Slot = Slots[SlotIndex];
if (Slot.IsEmpty() || !Slot.Item->WorldMesh)
{
SlotMesh->SetStaticMesh(nullptr);
SlotMesh->SetVisibility(false);
return;
}
// Mesh ET materiau : c'est souvent le materiau seul qui distingue le cru du
// cuit quand les deux etapes partagent la meme geometrie.
Slot.Item->ApplyWorldVisualsTo(SlotMesh);
SlotMesh->SetRelativeScale3D(FVector(CookingMeshScale));
SlotMesh->SetVisibility(true);
}
// ----------------------------------------------------------------------
// Visee et decision
// ----------------------------------------------------------------------
int32 ACookingStation::ResolveAimedSlot(const AActor* Interactor) const
{
if (!Interactor)
{
return INDEX_NONE;
}
FVector ViewLocation;
FRotator ViewRotation;
// GetActorEyesViewPoint : sur un pawn possede, il renvoie le point de vue du
// controller, donc exactement ce que le joueur voit. On evite ainsi de
// dependre d'AFpsPlayer -- n'importe quel acteur pourra cuisiner.
Interactor->GetActorEyesViewPoint(ViewLocation, ViewRotation);
const FVector ViewDirection = ViewRotation.Vector();
const float MinDot = FMath::Cos(FMath::DegreesToRadians(SlotAimHalfAngle));
int32 BestIndex = INDEX_NONE;
float BestDot = MinDot;
const int32 UsedSlots = FMath::Min(CookingSlotCount, MaxCookingSlots);
for (int32 Index = 0; Index < UsedSlots; ++Index)
{
const FVector ToSlot = SlotMeshes[Index]->GetComponentLocation() - ViewLocation;
if (ToSlot.IsNearlyZero())
{
continue;
}
// Produit scalaire et non distance a l'ecran : c'est l'ecart ANGULAIRE
// au centre du viseur qui compte, et il ne demande ni projection ni
// acces au viewport. Sous le cone, aucun emplacement n'est vise et le
// joueur s'adresse au foyer lui-meme.
const float Dot = FVector::DotProduct(ToSlot.GetSafeNormal(), ViewDirection);
if (Dot > BestDot)
{
BestDot = Dot;
BestIndex = Index;
}
}
return BestIndex;
}
UInventoryComponent* ACookingStation::GetInventoryOf(AActor* Interactor)
{
return Interactor ? Interactor->FindComponentByClass<UInventoryComponent>() : nullptr;
}
FCookingInteraction ACookingStation::ResolveAction(AActor* Interactor) const
{
FCookingInteraction Result;
Result.SlotIndex = ResolveAimedSlot(Interactor);
// Slots n'est dimensionne qu'au BeginPlay : un trace d'interaction qui
// arriverait avant lirait hors du tableau.
if (!Slots.IsValidIndex(Result.SlotIndex))
{
Result.SlotIndex = INDEX_NONE;
}
const UInventoryComponent* Inventory = GetInventoryOf(Interactor);
if (Inventory)
{
Result.HeldItem = Inventory->GetSelectedSlot().Item;
Result.FuelSourceSlot = FindFuelSource(Inventory, Result.FuelItem);
}
// 1. Un emplacement vise et occupe par quelque chose qu'on a le droit de
// reprendre. Recuperer passe avant tout le reste, sinon on ne pourrait
// jamais sortir sa viande en ayant du bois en main.
if (CanTakeFromSlot(Result.SlotIndex))
{
Result.Action = ECookingAction::TakeItem;
return Result;
}
// 2. Un emplacement vise et libre, quelque chose de cuisinable en main.
if (Result.SlotIndex != INDEX_NONE && Slots[Result.SlotIndex].IsEmpty()
&& Result.HeldItem && Result.HeldItem->CanBePlacedOnFire())
{
Result.Action = ECookingAction::PlaceItem;
return Result;
}
// 3. Du bois quelque part dans l'inventaire et de la place dans le foyer.
// Pas besoin de l'avoir en main : la main sert a CHOISIR le combustible,
// pas a autoriser le geste.
if (Result.FuelSourceSlot != INDEX_NONE && Fuel.Num() < GetFuelCapacity())
{
Result.Action = ECookingAction::AddFuel;
return Result;
}
// Pas de quatrieme cas : allumer n'est plus un geste du joueur.
return Result;
}
// ----------------------------------------------------------------------
// Actions
// ----------------------------------------------------------------------
bool ACookingStation::PlaceItemFromHand(int32 SlotIndex, AActor* Interactor)
{
UInventoryComponent* Inventory = GetInventoryOf(Interactor);
if (!Inventory)
{
return false;
}
// Un seul exemplaire, meme si la pile en contient vingt : un emplacement de
// grill porte une piece, comme dans Raft.
FInventorySlot Taken;
if (!Inventory->TakeFromSlot(Inventory->GetSelectedHotbarIndex(), 1, Taken) || Taken.IsEmpty())
{
return false;
}
FCookingSlotState& Slot = Slots[SlotIndex];
Slot.Item = Taken.Item;
Slot.RemainingUses = Taken.RemainingUses;
RefreshSlotMesh(SlotIndex);
StartSlotTimer(SlotIndex);
// Poser quelque chose EST ce qui declenche le feu, s'il a du bois.
UpdateFireState();
UGameplayStatics::PlaySoundAtLocation(this, PlaceItemSound, SlotMeshes[SlotIndex]->GetComponentLocation());
OnStationChanged.Broadcast();
return true;
}
bool ACookingStation::TakeItemToInventory(int32 SlotIndex, AActor* Interactor)
{
UInventoryComponent* Inventory = GetInventoryOf(Interactor);
if (!Inventory)
{
return false;
}
FCookingSlotState& Slot = Slots[SlotIndex];
// On tente l'ajout AVANT de vider l'emplacement. Inventaire plein : la
// nourriture reste sur le feu -- et continue donc de cuire, ce qui est la
// bonne sanction plutot que de la faire disparaitre.
const int32 Leftover = Inventory->AddItem(Slot.Item, 1, Slot.RemainingUses);
if (Leftover > 0)
{
return false;
}
GetWorldTimerManager().ClearTimer(Slot.Timer);
Slot.Item = nullptr;
Slot.RemainingUses = -1;
RefreshSlotMesh(SlotIndex);
// Le grill vide, plus rien ne justifie de bruler du bois.
UpdateFireState();
OnStationChanged.Broadcast();
return true;
}
// ----------------------------------------------------------------------
// IInteractable
// ----------------------------------------------------------------------
bool ACookingStation::CanInteract_Implementation(AActor* Interactor) const
{
// Toujours vrai : meme sans rien a faire, le poste doit rester visable pour
// que le prompt puisse annoncer son etat. C'est la seule interface qu'a le
// joueur pour savoir ou en est sa cuisson et combien de bois il lui reste.
// Le fait qu'une action soit possible ou non se dit par
// IsInteractionAvailable, qui n'efface pas le texte.
return true;
}
FText ACookingStation::GetInteractionPrompt_Implementation(AActor* Interactor) const
{
const FCookingInteraction Interaction = ResolveAction(Interactor);
switch (Interaction.Action)
{
case ECookingAction::TakeItem:
{
const FText ItemName = Slots[Interaction.SlotIndex].Item->DisplayName;
const float Remaining = GetSlotTimeRemaining(Interaction.SlotIndex);
// Exactement la formulation d'un objet au sol : une fois cuit, ce n'est
// plus une manipulation de four, c'est un ramassage.
if (!bShowRemainingTimeInPrompt || Remaining <= 0.f || !bIsLit)
{
return FText::Format(LOCTEXT("TakeDone", "Pick up {0}"), ItemName);
}
return FText::Format(LOCTEXT("TakeDoneTimed", "Pick up {0} ({1}s)"),
ItemName, FText::AsNumber(FMath::CeilToInt(Remaining)));
}
case ECookingAction::PlaceItem:
return FText::Format(LOCTEXT("PlaceOnFire", "Cook {0}"), Interaction.HeldItem->DisplayName);
case ECookingAction::AddFuel:
return FText::Format(LOCTEXT("AddFuel", "Add {0} to the fire"), Interaction.FuelItem->DisplayName);
default:
break;
}
// Aucune action possible : aucun texte. Tout ce qu'un commentaire d'etat
// dirait -- ca cuit, il manque du bois, le foyer est plein -- se lit deja
// sur les flammes, sur le nombre de buches et sur le mesh de la nourriture.
// L'ecrire en plus, c'est reconstruire une interface par-dessus un monde
// qui se suffit a lui-meme.
return FText::GetEmpty();
}
void ACookingStation::Interact_Implementation(AActor* Interactor)
{
// Meme fonction que le prompt : ce que le joueur vient de lire est
// exactement ce qui va se produire.
const FCookingInteraction Interaction = ResolveAction(Interactor);
switch (Interaction.Action)
{
case ECookingAction::TakeItem:
TakeItemToInventory(Interaction.SlotIndex, Interactor);
break;
case ECookingAction::PlaceItem:
PlaceItemFromHand(Interaction.SlotIndex, Interactor);
break;
case ECookingAction::AddFuel:
AddFuelFrom(Interaction.FuelSourceSlot, Interactor);
break;
default:
break;
}
}
void ACookingStation::OnBeginFocus_Implementation()
{
if (bHighlightOnFocus)
{
BaseMesh->SetRenderCustomDepth(true);
}
}
void ACookingStation::OnEndFocus_Implementation()
{
if (bHighlightOnFocus)
{
BaseMesh->SetRenderCustomDepth(false);
}
}
#undef LOCTEXT_NAMESPACE
@@ -13,6 +13,12 @@ UCraftingComponent::UCraftingComponent()
// Necessaire pour que InitializeComponent() soit appele.
bWantsInitializeComponent = true;
// Aucune propriete repliquee : le composant ne porte que StationRefCounts,
// tenu a jour des deux cotes par le controller. C'est uniquement le routage
// de Server_Craft qui l'exige -- un composant non replique voit ses RPC
// serveur silencieusement jetees.
SetIsReplicatedByDefault(true);
}
void UCraftingComponent::InitializeComponent()
@@ -198,8 +204,29 @@ void UCraftingComponent::GatherIngredientStatus(const UCraftingRecipeDataAsset*
}
}
void UCraftingComponent::Server_Craft_Implementation(UCraftingRecipeDataAsset* Recipe)
{
Craft(Recipe);
}
bool UCraftingComponent::Craft(UCraftingRecipeDataAsset* Recipe)
{
// Fabriquer consomme et ajoute dans l'inventaire, devenu autoritaire.
// Le client demande, le serveur decide -- et il refait CanCraft de son cote
// avec SA liste de postes accessibles : sans ca, un client desynchronise
// fabriquerait une recette d'etabli au milieu de la foret.
const AActor* Owner = GetOwner();
if (!Owner || !Owner->HasAuthority())
{
Server_Craft(Recipe);
// "Demande partie", pas "objet fabrique". Le resultat arrivera par la
// replication de l'inventaire, et OnCraftSucceeded n'est diffuse que
// sur le serveur -- une animation de reussite devra passer par une RPC
// Client_ le jour ou il y en aura une.
return true;
}
ECraftFailureReason Reason = ECraftFailureReason::None;
if (!CanCraft(Recipe, Reason))
{
+100 -6
View File
@@ -17,6 +17,7 @@
#include "InteractionComponent.h"
#include "InventoryComponent.h"
#include "ItemDataAsset.h"
#include "Net/UnrealNetwork.h"
#include "PickupItem.h"
#include "SurvivalStatsComponent.h"
#include "SurvivalUserSettings.h"
@@ -448,14 +449,58 @@ void AFpsPlayer::StartSprint()
return;
}
bIsSprinting = true;
GetCharacterMovement()->MaxWalkSpeed = SprintSpeed;
SetSprinting(true);
}
void AFpsPlayer::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
// COND_SkipOwner : celui qui sprinte le sait deja, il vient de l'appliquer
// en local. Le renvoyer ecraserait son etat courant par une valeur qui a un
// aller-retour de retard -- exactement ce qui fait "sauter" un sprint qu'on
// relance vite.
DOREPLIFETIME_CONDITION(AFpsPlayer, bIsSprinting, COND_SkipOwner);
}
void AFpsPlayer::StopSprint()
{
bIsSprinting = false;
GetCharacterMovement()->MaxWalkSpeed = WalkSpeed;
SetSprinting(false);
}
void AFpsPlayer::SetSprinting(bool bNewSprinting)
{
if (bIsSprinting == bNewSprinting)
{
return;
}
bIsSprinting = bNewSprinting;
ApplySprintSpeed();
// Applique en local d'abord pour que la vitesse soit instantanee sous les
// doigts, puis previent le serveur. Le sens inverse -- attendre la reponse --
// donnerait un sprint qui demarre avec un temps de retard.
if (!HasAuthority())
{
Server_SetSprinting(bNewSprinting);
}
}
void AFpsPlayer::Server_SetSprinting_Implementation(bool bNewSprinting)
{
bIsSprinting = bNewSprinting;
ApplySprintSpeed();
}
void AFpsPlayer::OnRep_IsSprinting()
{
ApplySprintSpeed();
}
void AFpsPlayer::ApplySprintSpeed()
{
GetCharacterMovement()->MaxWalkSpeed = bIsSprinting ? SprintSpeed : WalkSpeed;
}
void AFpsPlayer::HandleSprintReleased()
@@ -471,7 +516,33 @@ void AFpsPlayer::HandleSprintReleased()
void AFpsPlayer::UseSelectedItem()
{
const FInventorySlot Selected = InventoryComponent->GetSelectedSlot();
const int32 SelectedIndex = InventoryComponent->GetSelectedHotbarIndex();
// L'hote agit tout de suite, le client demande. On envoie meme si le slot
// parait vide chez nous : c'est le serveur qui fait foi, et son inventaire
// peut avoir une frame d'avance sur le notre.
if (!HasAuthority())
{
Server_UseItem(SelectedIndex);
return;
}
PerformUseItem(SelectedIndex);
}
void AFpsPlayer::Server_UseItem_Implementation(int32 SlotIndex)
{
PerformUseItem(SlotIndex);
}
void AFpsPlayer::PerformUseItem(int32 SlotIndex)
{
if (!HasAuthority() || !InventoryComponent->GetSlots().IsValidIndex(SlotIndex))
{
return;
}
const FInventorySlot Selected = InventoryComponent->GetSlots()[SlotIndex];
if (Selected.IsEmpty())
{
return;
@@ -493,7 +564,7 @@ void AFpsPlayer::UseSelectedItem()
// usages multiples (une charge en moins, l'objet reste tant qu'il en a).
// On vise le slot actif precisement, et pas RemoveItem qui piocherait
// n'importe quelle pile du meme objet, y compris dans le sac.
InventoryComponent->ConsumeUse(InventoryComponent->GetSelectedHotbarIndex());
InventoryComponent->ConsumeUse(SlotIndex);
}
FVector AFpsPlayer::PlaceOnGround(const FVector& Start) const
@@ -543,6 +614,11 @@ bool AFpsPlayer::DropSlot(int32 SlotIndex, int32 Quantity)
return DropFromInventory(InventoryComponent, SlotIndex, Quantity);
}
void AFpsPlayer::Server_DropFromInventory_Implementation(UInventoryComponent* Inventory, int32 SlotIndex, int32 Quantity)
{
DropFromInventory(Inventory, SlotIndex, Quantity);
}
bool AFpsPlayer::DropFromInventory(UInventoryComponent* Inventory, int32 SlotIndex, int32 Quantity)
{
if (!Inventory)
@@ -550,6 +626,16 @@ bool AFpsPlayer::DropFromInventory(UInventoryComponent* Inventory, int32 SlotInd
return false;
}
// Jeter engendre un acteur dans le monde : c'est une action d'autorite.
// La position de depot est calculee cote SERVEUR a partir de la camera
// repliquee du pawn -- a quelques centimetres pres du point vise par le
// client, ce qui n'a aucune importance pour un objet pose au sol.
if (!HasAuthority())
{
Server_DropFromInventory(Inventory, SlotIndex, Quantity);
return true;
}
if (!DroppedPickupClass)
{
UE_LOG(LogTemp, Warning, TEXT("AFpsPlayer : DroppedPickupClass n'est pas assignee, impossible de jeter un objet."));
@@ -618,6 +704,14 @@ void AFpsPlayer::HandleCraftOverflow(UItemDataAsset* Item, int32 Quantity, int32
void AFpsPlayer::DropAllItems()
{
// Declenche par la mort, qui sera arbitree par le serveur. La garde est la
// pour le jour ou un client l'appellerait : il engendrerait des pickups
// visibles de lui seul, qui disparaitraient au premier nettoyage reseau.
if (!HasAuthority())
{
return;
}
if (!DroppedPickupClass)
{
UE_LOG(LogTemp, Warning, TEXT("AFpsPlayer : DroppedPickupClass n'est pas assignee, le butin est perdu."));
@@ -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)
@@ -658,9 +768,19 @@ void AFpsPlayerController::ReleaseActiveStorage()
GetWorldTimerManager().ClearTimer(StorageDistanceTimerHandle);
if (AStorageContainer* Container = ActiveStorage.Get())
{
// 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();
if (InventoryScreen)
@@ -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);
}
@@ -12,12 +12,26 @@
UInteractionComponent::UInteractionComponent()
{
PrimaryComponentTick.bCanEverTick = true;
// Indispensable pour que Server_Interact soit route : un composant non
// replique voit ses RPC serveur silencieusement jetes par le moteur. Aucune
// propriete n'est repliquee pour autant, seul le routage nous interesse.
SetIsReplicatedByDefault(true);
}
void UInteractionComponent::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction)
{
Super::TickComponent(DeltaTime, TickType, ThisTickFunction);
// Le trace ne sert qu'a afficher un prompt, donc uniquement a celui qui
// regarde. Sans ce filtre, l'hote lancerait un trace physique pour CHACUN
// des quatre pawns a 20 Hz, dont trois dont il n'affichera jamais l'UI.
const APawn* OwnerPawn = Cast<APawn>(GetOwner());
if (!OwnerPawn || !OwnerPawn->IsLocallyControlled())
{
return;
}
// On espace les traces : c'est une requete physique, pas gratuite, et
// l'affichage du prompt n'a pas besoin d'etre rafraichi chaque frame.
TimeSinceLastTrace += DeltaTime;
@@ -158,14 +172,55 @@ void UInteractionComponent::TryInteract()
return;
}
IInteractable::Execute_Interact(Target, GetOwner());
// L'hote agit directement, le client demande. Le test porte sur l'autorite
// et non sur "suis-je un client" : sur un serveur d'ecoute, le joueur qui
// heberge passe par la premiere branche et ne paie aucune latence.
if (GetOwner()->HasAuthority())
{
PerformInteract(Target);
}
else
{
Server_Interact(Target);
}
// L'interaction vient peut-etre de detruire la cible, ou de la rendre
// non-interactive (porte maintenant ouverte). On reevalue tout de suite
// plutot que d'attendre le prochain trace periodique.
//
// Cote client la cible est encore la a cet instant -- le serveur n'a pas
// encore repondu. Ce n'est pas grave : la destruction repliquee videra
// FocusedActor toute seule, et bHasFocusedActor fera diffuser la transition.
UpdateFocus();
}
void UInteractionComponent::Server_Interact_Implementation(AActor* Target)
{
PerformInteract(Target);
}
void UInteractionComponent::PerformInteract(AActor* Target)
{
AActor* Owner = GetOwner();
if (!Owner || !Owner->HasAuthority() || !IsValidInteractable(Target))
{
return;
}
// Le client a vise il y a un aller-retour : entre-temps un autre joueur a pu
// ramasser l'objet, ou s'en eloigner. On revalide donc la distance ici, avec
// la meme portee que le trace mais elargie par ServerRangeTolerance.
const float MaxDistance = InteractionRange * ServerRangeTolerance;
if (FVector::DistSquared(Owner->GetActorLocation(), Target->GetActorLocation()) > FMath::Square(MaxDistance))
{
UE_LOG(LogTemp, Verbose, TEXT("UInteractionComponent : %s a demande %s, hors de portee cote serveur."),
*GetNameSafe(Owner), *GetNameSafe(Target));
return;
}
IInteractable::Execute_Interact(Target, Owner);
}
void UInteractionComponent::RefreshPrompt()
{
AActor* Target = FocusedActor.Get();
@@ -4,6 +4,7 @@
#include "InventoryComponent.h"
#include "ItemDataAsset.h"
#include "Net/UnrealNetwork.h"
UInventoryComponent::UInventoryComponent()
{
@@ -11,6 +12,9 @@ UInventoryComponent::UInventoryComponent()
// Necessaire pour que InitializeComponent() soit appele.
bWantsInitializeComponent = true;
// Le contenu appartient au serveur et descend vers les clients.
SetIsReplicatedByDefault(true);
}
void UInventoryComponent::InitializeComponent()
@@ -22,6 +26,30 @@ void UInventoryComponent::InitializeComponent()
SelectedHotbarIndex = FMath::Clamp(SelectedHotbarIndex, 0, FMath::Max(0, GetHotbarSlotCount() - 1));
}
void UInventoryComponent::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(UInventoryComponent, Slots);
}
void UInventoryComponent::OnRep_Slots()
{
// Pas de BroadcastChanged() ici : le regroupement differe ne sert qu'a
// eviter vingt reconstructions d'UI pendant un "Tout ranger" execute en
// local. La replication, elle, arrive deja groupee -- le serveur a fini son
// lot avant d'envoyer quoi que ce soit.
OnInventoryChanged.Broadcast();
}
/**
* SelectedHotbarIndex n'est volontairement PAS replique : le slot actif est une
* intention purement locale, chaque joueur choisit le sien et personne d'autre
* n'a besoin de le connaitre. Le jour ou l'objet en main devra etre visible par
* les autres, ce sera une propriete du PAWN -- ce qu'on tient dans sa main est
* une donnee de corps, pas de sac.
*/
void UInventoryComponent::SelectHotbarSlot(int32 Index)
{
const int32 Count = GetHotbarSlotCount();
@@ -178,8 +206,27 @@ int32 UInventoryComponent::RemoveItem(UItemDataAsset* Item, int32 Quantity)
return Removed;
}
void UInventoryComponent::Server_TransferAllTo_Implementation(UInventoryComponent* From, UInventoryComponent* To, int32 FirstIndex)
{
if (From)
{
From->TransferAllTo(To, FirstIndex);
}
}
void UInventoryComponent::Server_ConsumeUse_Implementation(int32 SlotIndex)
{
ConsumeUse(SlotIndex);
}
bool UInventoryComponent::ConsumeUse(int32 SlotIndex)
{
if (!HasInventoryAuthority())
{
Server_ConsumeUse(SlotIndex);
return true;
}
if (!Slots.IsValidIndex(SlotIndex))
{
return false;
@@ -419,6 +466,28 @@ bool UInventoryComponent::ApplySlotMove(FInventorySlot& From, FInventorySlot& To
return true;
}
bool UInventoryComponent::HasInventoryAuthority() const
{
const AActor* Owner = GetOwner();
return Owner && Owner->HasAuthority();
}
UInventoryComponent* UInventoryComponent::FindNetProxy(UInventoryComponent* A, UInventoryComponent* B)
{
auto IsLocalPawnInventory = [](const UInventoryComponent* Inventory)
{
const APawn* OwnerPawn = Inventory ? Cast<APawn>(Inventory->GetOwner()) : nullptr;
return OwnerPawn && OwnerPawn->IsLocallyControlled();
};
if (IsLocalPawnInventory(A))
{
return A;
}
return IsLocalPawnInventory(B) ? B : nullptr;
}
bool UInventoryComponent::TransferSlot(UInventoryComponent* From, int32 FromIndex, UInventoryComponent* To, int32 ToIndex, int32 Quantity)
{
if (!From || !To || Quantity <= 0)
@@ -426,6 +495,43 @@ bool UInventoryComponent::TransferSlot(UInventoryComponent* From, int32 FromInde
return false;
}
// Chez un client : on ne touche a rien, on demande. Le contenu redescendra
// par OnRep_Slots, qui rediffuse OnInventoryChanged et rafraichit l'UI.
if (!From->HasInventoryAuthority())
{
UInventoryComponent* Proxy = FindNetProxy(From, To);
if (!Proxy)
{
UE_LOG(LogTemp, Warning, TEXT("UInventoryComponent::TransferSlot : aucun inventaire de joueur local dans l'echange, demande abandonnee."));
return false;
}
Proxy->Server_TransferSlot(From, FromIndex, To, ToIndex, Quantity);
// True signifie ici "demande partie", pas "contenu modifie". Les
// appelants s'en servent pour savoir si le geste a ete pris en compte,
// ce qui reste exact.
return true;
}
return ApplyTransferSlot(From, FromIndex, To, ToIndex, Quantity);
}
void UInventoryComponent::Server_TransferSlot_Implementation(UInventoryComponent* From, int32 FromIndex, UInventoryComponent* To, int32 ToIndex, int32 Quantity)
{
// From et To arrivent par le reseau : ils peuvent etre nuls si l'acteur a
// ete detruit entre l'envoi et l'arrivee -- un coffre casse, un joueur
// deconnecte. ApplyTransferSlot les revalide de toute facon.
ApplyTransferSlot(From, FromIndex, To, ToIndex, Quantity);
}
bool UInventoryComponent::ApplyTransferSlot(UInventoryComponent* From, int32 FromIndex, UInventoryComponent* To, int32 ToIndex, int32 Quantity)
{
if (!From || !To || Quantity <= 0)
{
return false;
}
// Meme inventaire et meme case : rien a faire. Le test doit porter sur le
// couple et non sur l'index seul, deux inventaires differents ayant tous
// les deux une case 3.
@@ -491,6 +597,21 @@ int32 UInventoryComponent::QuickTransferSlot(UInventoryComponent* From, int32 Fr
return 0;
}
if (!From->HasInventoryAuthority())
{
if (UInventoryComponent* Proxy = FindNetProxy(From, To))
{
Proxy->Server_QuickTransferSlot(From, FromIndex, To);
// Le nombre reellement deplace n'est connu que du serveur. On rend
// une valeur non nulle pour que l'appelant sache que le clic a ete
// pris en compte ; l'affichage, lui, viendra de OnRep_Slots.
return 1;
}
return 0;
}
const int32 Moved = From->PushSlotInto(FromIndex, *To);
if (Moved > 0)
{
@@ -500,6 +621,11 @@ int32 UInventoryComponent::QuickTransferSlot(UInventoryComponent* From, int32 Fr
return Moved;
}
void UInventoryComponent::Server_QuickTransferSlot_Implementation(UInventoryComponent* From, int32 FromIndex, UInventoryComponent* To)
{
QuickTransferSlot(From, FromIndex, To);
}
int32 UInventoryComponent::TransferAllTo(UInventoryComponent* To, int32 FirstIndex)
{
if (!To || To == this)
@@ -507,6 +633,17 @@ int32 UInventoryComponent::TransferAllTo(UInventoryComponent* To, int32 FirstInd
return 0;
}
if (!HasInventoryAuthority())
{
if (UInventoryComponent* Proxy = FindNetProxy(this, To))
{
Proxy->Server_TransferAllTo(this, To, FirstIndex);
return 1;
}
return 0;
}
To->BeginBatch();
int32 Moved = 0;
@@ -10,6 +10,7 @@
#include "MainMenuWidget.h"
#include "MenuCameraSpot.h"
#include "ScreenFadeComponent.h"
#include "SessionSubsystem.h"
#include "SettingsMenuWidget.h"
#include "SurvivalGameInstance.h"
@@ -78,6 +79,18 @@ void AMainMenuPlayerController::BeginPlay()
ScreenFade->OnFadeFinished.AddDynamic(this, &AMainMenuPlayerController::HandleFadeFinished);
ScreenFade->FadeIn(OpeningFadeDuration);
if (USessionSubsystem* Sessions = GetGameInstance() ? GetGameInstance()->GetSubsystem<USessionSubsystem>() : nullptr)
{
Sessions->OnHostFailed.AddDynamic(this, &AMainMenuPlayerController::HandleHostFailed);
Sessions->OnJoinFailed.AddDynamic(this, &AMainMenuPlayerController::HandleJoinFailed);
// Revenir au menu depuis une partie laisse la session ouverte : elle
// resterait annoncee a tes amis alors que plus personne n'est dedans.
Sessions->LeaveSession();
UE_LOG(LogTemp, Log, TEXT("AMainMenuPlayerController : service en ligne [%s]."), *Sessions->GetOnlineServiceName());
}
}
AMenuCameraSpot* AMainMenuPlayerController::PickCameraSpot()
@@ -135,7 +148,7 @@ AMenuCameraSpot* AMainMenuPlayerController::PickCameraSpot()
return Chosen;
}
void AMainMenuPlayerController::StartSoloGame()
void AMainMenuPlayerController::StartGame()
{
// Le fondu est deja lance : on ignore le second clic plutot que d'empiler
// deux chargements.
@@ -162,6 +175,33 @@ void AMainMenuPlayerController::StartSoloGame()
ScreenFade->FadeOut(LeaveFadeDuration);
}
void AMainMenuPlayerController::HandleHostFailed(FText Reason)
{
UE_LOG(LogTemp, Warning, TEXT("AMainMenuPlayerController : session indisponible (%s), la partie se lance hors ligne."), *Reason.ToString());
// Sans session on ouvre quand meme en serveur d'ecoute plutot qu'en solo
// sec : l'ecart de cout est nul a un joueur, et ca evite d'avoir deux
// facons differentes de demarrer une partie a maintenir.
UGameplayStatics::OpenLevelBySoftObjectPtr(this, GameLevel, /*bAbsolute=*/true, TEXT("listen"));
}
void AMainMenuPlayerController::HandleJoinFailed(FText Reason)
{
UE_LOG(LogTemp, Error, TEXT("AMainMenuPlayerController : connexion impossible -- %s"), *Reason.ToString());
// Ici pas de repli : le joueur voulait rejoindre un ami precis. On le rend
// simplement a son menu, ecran remonte -- sans quoi un echec le laisserait
// devant un noir definitif, ce qui se lit comme un plantage.
PendingAction = EMenuPendingAction::None;
if (MainMenu)
{
MainMenu->SetVisibility(ESlateVisibility::Visible);
}
ScreenFade->FadeIn(LeaveFadeDuration);
}
void AMainMenuPlayerController::QuitGame()
{
// Une partie est deja en cours de chargement : la question n'a plus de sens.
@@ -287,9 +327,22 @@ void AMainMenuPlayerController::HandleFadeFinished()
{
// Le delegue se declenche aussi a la fin du fondu d'ouverture, ou il n'y a
// rien a faire : c'est PendingAction qui distingue les deux cas.
if (PendingAction == EMenuPendingAction::StartGame)
if (PendingAction != EMenuPendingAction::StartGame)
{
UGameplayStatics::OpenLevelBySoftObjectPtr(this, GameLevel);
return;
}
// C'est le sous-systeme qui ouvrira la map, et seulement une fois la session
// reellement creee : ouvrir avant donnerait une partie que personne ne peut
// rejoindre. Le cas d'echec passe par HandleHostFailed, qui charge quand meme.
if (USessionSubsystem* Sessions = GetGameInstance() ? GetGameInstance()->GetSubsystem<USessionSubsystem>() : nullptr)
{
Sessions->HostSession(GameLevel, USessionSubsystem::DefaultMaxPlayers);
}
else
{
UE_LOG(LogTemp, Warning, TEXT("AMainMenuPlayerController : USessionSubsystem introuvable, partie lancee hors ligne."));
UGameplayStatics::OpenLevelBySoftObjectPtr(this, GameLevel, /*bAbsolute=*/true, TEXT("listen"));
}
}
@@ -25,24 +25,13 @@ void UMainMenuWidget::NativeConstruct()
SettingsButton->OnClicked.AddDynamic(this, &UMainMenuWidget::HandleSettingsClicked);
}
// Les boutons de coop sont visibles mais inertes : ils annoncent la couleur
// sans promettre une partie qui echouerait.
if (HostButton)
{
HostButton->SetIsEnabled(false);
}
if (JoinButton)
{
JoinButton->SetIsEnabled(false);
}
}
void UMainMenuWidget::HandlePlayClicked()
{
if (AMainMenuPlayerController* Controller = GetOwningPlayer<AMainMenuPlayerController>())
{
Controller->StartSoloGame();
Controller->StartGame();
}
}
@@ -24,6 +24,17 @@ void UPauseMenuWidget::NativeConstruct()
{
SettingsButton->OnClicked.AddDynamic(this, &UPauseMenuWidget::HandleSettingsClicked);
}
if (InviteButton)
{
InviteButton->OnClicked.AddDynamic(this, &UPauseMenuWidget::HandleInviteClicked);
// Evalue une seule fois : le menu de pause n'est construit qu'a la
// premiere ouverture, donc bien apres que la session ait reussi ou
// echoue. Rien ne peut plus changer cet etat en cours de partie.
const AFpsPlayerController* Controller = GetOwningPlayer<AFpsPlayerController>();
InviteButton->SetIsEnabled(Controller && Controller->CanInviteFriends());
}
}
void UPauseMenuWidget::NativeDestruct()
@@ -43,6 +54,11 @@ void UPauseMenuWidget::NativeDestruct()
QuitToMenuButton->OnClicked.RemoveDynamic(this, &UPauseMenuWidget::HandleQuitToMenuClicked);
}
if (InviteButton)
{
InviteButton->OnClicked.RemoveDynamic(this, &UPauseMenuWidget::HandleInviteClicked);
}
Super::NativeDestruct();
}
@@ -69,3 +85,11 @@ void UPauseMenuWidget::HandleQuitToMenuClicked()
Controller->RequestQuitToMainMenu();
}
}
void UPauseMenuWidget::HandleInviteClicked()
{
if (AFpsPlayerController* Controller = GetOwningPlayer<AFpsPlayerController>())
{
Controller->ShowInviteFriends();
}
}
+51 -21
View File
@@ -7,6 +7,7 @@
#include "Components/StaticMeshComponent.h"
#include "InventoryComponent.h"
#include "ItemDataAsset.h"
#include "Net/UnrealNetwork.h"
#define LOCTEXT_NAMESPACE "PickupItem"
@@ -14,6 +15,15 @@ APickupItem::APickupItem()
{
PrimaryActorTick.bCanEverTick = false;
// Acteur du monde : le serveur fait autorite. Un pickup ramasse doit
// disparaitre chez les quatre joueurs, pas seulement chez celui qui l'a pris.
bReplicates = true;
// Un pickup ne bouge plus une fois pose. Couper la replication de mouvement
// evite d'envoyer une transform a chaque joueur qui entre dans sa zone de
// pertinence, alors que celle du spawn suffit.
SetReplicateMovement(false);
Mesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("Mesh"));
SetRootComponent(Mesh);
// Purement decoratif : c'est la sphere qui porte la collision.
@@ -29,6 +39,15 @@ APickupItem::APickupItem()
InteractionSphere->SetCollisionResponseToChannel(ECC_Visibility, ECR_Block);
}
void APickupItem::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(APickupItem, ItemData);
DOREPLIFETIME(APickupItem, Quantity);
DOREPLIFETIME(APickupItem, RemainingUses);
}
void APickupItem::InitPickup(UItemDataAsset* InItemData, int32 InQuantity, int32 InRemainingUses)
{
ItemData = InItemData;
@@ -36,6 +55,19 @@ void APickupItem::InitPickup(UItemDataAsset* InItemData, int32 InQuantity, int32
RemainingUses = InRemainingUses;
}
void APickupItem::ApplyItemVisuals()
{
if (bUseMeshFromItemData && ItemData && ItemData->WorldMesh)
{
ItemData->ApplyWorldVisualsTo(Mesh);
}
}
void APickupItem::OnRep_ItemData()
{
ApplyItemVisuals();
}
void APickupItem::OnConstruction(const FTransform& Transform)
{
Super::OnConstruction(Transform);
@@ -43,10 +75,10 @@ void APickupItem::OnConstruction(const FTransform& Transform)
// Confort d'edition : le mesh suit l'objet choisi, tu vois immediatement
// dans le viewport ce que tu es en train de poser. Vaut aussi a l'execution
// pour les objets jetes, puisque OnConstruction s'execute a FinishSpawning.
if (bUseMeshFromItemData && ItemData && ItemData->WorldMesh)
{
ItemData->ApplyWorldVisualsTo(Mesh);
}
//
// Chez un client, ItemData est encore nul a cet instant : c'est OnRep_ItemData
// qui reposera le visuel quand la propriete arrivera.
ApplyItemVisuals();
}
FText APickupItem::GetInteractionPrompt_Implementation(AActor* Interactor) const
@@ -75,6 +107,16 @@ bool APickupItem::CanInteract_Implementation(AActor* Interactor) const
void APickupItem::Interact_Implementation(AActor* Interactor)
{
// Filet de securite : UInteractionComponent ne nous appelle deja que cote
// autorite, mais un Blueprint ou un futur appelant pourrait l'oublier, et
// un ramassage joue en local donnerait un objet qui reapparait a la
// prochaine replication -- le pire des symptomes, parce qu'il est
// intermittent.
if (!HasAuthority())
{
return;
}
UInventoryComponent* Inventory = Interactor ? Interactor->FindComponentByClass<UInventoryComponent>() : nullptr;
if (!Inventory)
{
@@ -83,30 +125,18 @@ void APickupItem::Interact_Implementation(AActor* Interactor)
}
const int32 Leftover = Inventory->AddItem(ItemData, Quantity, RemainingUses);
const int32 Added = Quantity - Leftover;
// Inventaire plein : on ne detruit surtout pas le tas, on retire seulement
// ce qui est rentre. Le joueur peut revenir chercher le reste.
if (Added <= 0)
if (Leftover >= Quantity)
{
if (GEngine)
{
GEngine->AddOnScreenDebugMessage(-1, 2.f, FColor::Red, TEXT("Inventaire plein"));
}
return;
}
// Retour temporaire, le temps que l'UI d'inventaire existe.
if (GEngine)
{
GEngine->AddOnScreenDebugMessage(-1, 2.f, FColor::Green,
FString::Printf(TEXT("+%d %s (total : %d, slots : %d/%d)"),
Added,
*ItemData->DisplayName.ToString(),
Inventory->GetItemCount(ItemData),
Inventory->GetUsedSlotCount(),
Inventory->GetTotalSlotCount()));
}
// Les anciens AddOnScreenDebugMessage ont disparu : en reseau ils
// s'affichaient sur l'ecran de l'HOTE, puisque c'est lui qui execute ce
// code, y compris quand c'est un client qui ramasse. L'UI d'inventaire
// existe desormais et dit la meme chose au bon joueur.
if (Leftover > 0)
{
@@ -0,0 +1,141 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "PlayerNameplateComponent.h"
#include "Engine/Engine.h"
#include "Engine/World.h"
#include "GameFramework/Pawn.h"
#include "GameFramework/PlayerController.h"
#include "GameFramework/PlayerState.h"
#include "PlayerNameplateWidget.h"
#include "TimerManager.h"
UPlayerNameplateComponent::UPlayerNameplateComponent()
{
// Espace ecran : l'etiquette fait toujours face a la camera, par
// construction, et n'est jamais occultee par le decor.
SetWidgetSpace(EWidgetSpace::Screen);
// Le widget se dimensionne sur son contenu : un pseudo court n'a pas a
// trainer une boite de la largeur du plus long.
SetDrawAtDesiredSize(true);
// Une etiquette ne se vise pas et n'arrete pas un trace : sans ca, elle
// bloquerait le canal Visibility et masquerait l'interaction du joueur
// qu'elle surmonte.
SetCollisionEnabled(ECollisionEnabled::NoCollision);
SetGenerateOverlapEvents(false);
// Au-dessus de la tete. Reglable dans le Blueprint, la capsule et la taille
// du personnage pouvant changer.
SetRelativeLocation(FVector(0.f, 0.f, 100.f));
// Cachee au depart : la premiere passe de Refresh decidera. Sans ca, une
// etiquette sans nom clignote pendant la frame qui precede l'arrivee du
// PlayerState.
SetVisibility(false);
}
void UPlayerNameplateComponent::BeginPlay()
{
Super::BeginPlay();
// Un serveur dedie ne rend rien : lui faire calculer des distances pour des
// etiquettes que personne ne verra serait du gaspillage pur. Sans effet sur
// notre serveur d'ecoute, qui n'est pas dans ce mode.
if (GetNetMode() == NM_DedicatedServer)
{
return;
}
const APawn* OwnerPawn = Cast<APawn>(GetOwner());
// Son propre pseudo : on eteint definitivement, sans timer. Ce n'est pas un
// cas a reevaluer -- un pawn ne change pas de proprietaire en cours de vie,
// et apres un respawn c'est un NOUVEL acteur qui repassera par ici.
if (bHideOnOwningPlayer && OwnerPawn && OwnerPawn->IsLocallyControlled())
{
SetVisibility(false);
return;
}
GetWorld()->GetTimerManager().SetTimer(
RefreshTimer, this, &UPlayerNameplateComponent::Refresh,
FMath::Max(RefreshInterval, 0.05f), /*bLoop=*/true);
// Une premiere passe tout de suite : attendre RefreshInterval laisserait
// l'etiquette absente un quart de seconde a l'apparition d'un coequipier.
Refresh();
}
void UPlayerNameplateComponent::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
// Un timer laisse en vie apres la destruction du composant rappellerait une
// methode sur un objet mort -- typiquement a la mort d'un joueur, ou son
// pawn est detruit.
if (UWorld* World = GetWorld())
{
World->GetTimerManager().ClearTimer(RefreshTimer);
}
Super::EndPlay(EndPlayReason);
}
APlayerState* UPlayerNameplateComponent::GetOwnerPlayerState() const
{
const APawn* OwnerPawn = Cast<APawn>(GetOwner());
return OwnerPawn ? OwnerPawn->GetPlayerState() : nullptr;
}
void UPlayerNameplateComponent::Refresh()
{
// Le point de vue du joueur LOCAL, et non celui du pawn porteur : c'est de
// notre ecran qu'on parle. GetFirstLocalPlayerController et non
// GetFirstPlayerController -- en PIE a deux fenetres, le second rendrait le
// controller de l'autre instance et l'etiquette clignoterait.
const APlayerController* Viewer = GEngine ? GEngine->GetFirstLocalPlayerController(GetWorld()) : nullptr;
if (!Viewer || !Viewer->PlayerCameraManager)
{
SetVisibility(false);
return;
}
const float DistanceSquared = FVector::DistSquared(
Viewer->PlayerCameraManager->GetCameraLocation(),
GetComponentLocation());
if (DistanceSquared > FMath::Square(MaxDrawDistance))
{
SetVisibility(false);
return;
}
// Le PlayerState arrive apres le pawn chez un client : tant qu'il manque,
// on reste invisible plutot que d'afficher une etiquette vide. La passe
// suivante le trouvera.
const APlayerState* State = GetOwnerPlayerState();
if (!State)
{
SetVisibility(false);
return;
}
const FString CurrentName = State->GetPlayerName();
if (CurrentName.IsEmpty())
{
SetVisibility(false);
return;
}
if (CurrentName != LastShownName)
{
if (UPlayerNameplateWidget* Nameplate = Cast<UPlayerNameplateWidget>(GetUserWidgetObject()))
{
Nameplate->SetPlayerName(FText::FromString(CurrentName));
LastShownName = CurrentName;
}
}
SetVisibility(true);
}
@@ -0,0 +1,14 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "PlayerNameplateWidget.h"
#include "Components/TextBlock.h"
void UPlayerNameplateWidget::SetPlayerName(const FText& InName)
{
if (PlayerNameText)
{
PlayerNameText->SetText(InName);
}
}
@@ -0,0 +1,447 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "SessionSubsystem.h"
#include "GameFramework/PlayerController.h"
#include "Interfaces/OnlineExternalUIInterface.h"
#include "Kismet/GameplayStatics.h"
#include "Online/OnlineSessionNames.h"
#include "OnlineSessionSettings.h"
#include "OnlineSubsystem.h"
#define LOCTEXT_NAMESPACE "Session"
// Namespace NOMME et non anonyme : le build unity concatene les .cpp du module,
// et deux constantes homonymes dans deux fichiers deviennent une redefinition
// qui ne sort qu'au premier rebuild complet.
namespace SessionSubsystemConstants
{
/**
* Cle plantee dans les reglages de session et redemandee a la recherche.
* Sans elle on tomberait sur les parties de n'importe quel autre jeu partageant
* l'AppId de test 480 (Spacewar), qui sont legion.
*/
const FName GameKeyName(TEXT("EMBERWILD"));
const TCHAR* GameKeyValue = TEXT("Coop");
/** Le service charge quand Steam n'est pas disponible. */
const FName NullServiceName(TEXT("NULL"));
}
void USessionSubsystem::Initialize(FSubsystemCollectionBase& Collection)
{
Super::Initialize(Collection);
IOnlineSubsystem* Online = IOnlineSubsystem::Get();
if (!Online)
{
UE_LOG(LogTemp, Warning, TEXT("USessionSubsystem : aucun OnlineSubsystem charge, la coop sera indisponible."));
return;
}
SessionInterface = Online->GetSessionInterface();
if (!SessionInterface.IsValid())
{
UE_LOG(LogTemp, Warning, TEXT("USessionSubsystem : le service [%s] n'expose pas d'interface de session."), *Online->GetSubsystemName().ToString());
return;
}
InviteAcceptedHandle = SessionInterface->AddOnSessionUserInviteAcceptedDelegate_Handle(
FOnSessionUserInviteAcceptedDelegate::CreateUObject(this, &USessionSubsystem::HandleInviteAccepted));
UE_LOG(LogTemp, Log, TEXT("USessionSubsystem : service en ligne [%s], mode LAN [%d]."),
*Online->GetSubsystemName().ToString(), IsLanMode() ? 1 : 0);
}
void USessionSubsystem::Deinitialize()
{
if (SessionInterface.IsValid())
{
ClearPendingDelegates();
SessionInterface->ClearOnSessionUserInviteAcceptedDelegate_Handle(InviteAcceptedHandle);
}
SearchSettings.Reset();
SessionInterface.Reset();
Super::Deinitialize();
}
bool USessionSubsystem::IsLanMode() const
{
const IOnlineSubsystem* Online = IOnlineSubsystem::Get();
return !Online || Online->GetSubsystemName() == SessionSubsystemConstants::NullServiceName;
}
FString USessionSubsystem::GetOnlineServiceName() const
{
const IOnlineSubsystem* Online = IOnlineSubsystem::Get();
return Online ? Online->GetSubsystemName().ToString() : TEXT("None");
}
bool USessionSubsystem::IsSessionActive() const
{
return SessionInterface.IsValid() && SessionInterface->GetNamedSession(NAME_GameSession) != nullptr;
}
int32 USessionSubsystem::GetFoundSessionCount() const
{
return SearchSettings.IsValid() ? SearchSettings->SearchResults.Num() : 0;
}
void USessionSubsystem::ClearPendingDelegates()
{
if (!SessionInterface.IsValid())
{
return;
}
SessionInterface->ClearOnCreateSessionCompleteDelegate_Handle(CreateSessionCompleteHandle);
SessionInterface->ClearOnFindSessionsCompleteDelegate_Handle(FindSessionsCompleteHandle);
SessionInterface->ClearOnJoinSessionCompleteDelegate_Handle(JoinSessionCompleteHandle);
SessionInterface->ClearOnDestroySessionCompleteDelegate_Handle(DestroySessionCompleteHandle);
}
// ----------------------------------------------------------------------------
// Hebergement
// ----------------------------------------------------------------------------
void USessionSubsystem::HostSession(const TSoftObjectPtr<UWorld>& Level, int32 MaxPlayers)
{
if (!SessionInterface.IsValid())
{
OnHostFailed.Broadcast(LOCTEXT("HostNoService", "No online service available."));
return;
}
if (Level.IsNull())
{
UE_LOG(LogTemp, Error, TEXT("USessionSubsystem::HostSession : aucune map fournie."));
OnHostFailed.Broadcast(LOCTEXT("HostNoLevel", "No level configured for the game."));
return;
}
PendingHostLevel = Level;
PendingMaxPlayers = FMath::Clamp(MaxPlayers, 1, 16);
// Une session survit a un retour au menu si personne ne l'a detruite. Le
// moteur en refuse alors une seconde du meme nom, et l'echec est muet.
if (SessionInterface->GetNamedSession(NAME_GameSession) != nullptr)
{
bCreateAfterDestroy = true;
DestroySessionCompleteHandle = SessionInterface->AddOnDestroySessionCompleteDelegate_Handle(
FOnDestroySessionCompleteDelegate::CreateUObject(this, &USessionSubsystem::HandleDestroySessionComplete));
SessionInterface->DestroySession(NAME_GameSession);
return;
}
CreateSessionNow();
}
void USessionSubsystem::CreateSessionNow()
{
FOnlineSessionSettings Settings;
Settings.NumPublicConnections = PendingMaxPlayers;
Settings.NumPrivateConnections = 0;
Settings.bIsLANMatch = IsLanMode();
Settings.bShouldAdvertise = true;
Settings.bAllowJoinInProgress = true;
Settings.bAllowJoinViaPresence = true;
Settings.bAllowInvites = true;
Settings.bUsesPresence = true;
// LE drapeau a ne pas oublier. Steam ne route le trafic P2P que par un LOBBY :
// sans lui la session est bien creee, elle apparait meme dans une recherche,
// mais aucune connexion n'aboutit en dehors du LAN.
Settings.bUseLobbiesIfAvailable = true;
Settings.bUseLobbiesVoiceChatIfAvailable = false;
Settings.bUsesStats = false;
Settings.bAntiCheatProtected = false;
Settings.Set(SETTING_MAPNAME, PendingHostLevel.GetAssetName(), EOnlineDataAdvertisementType::ViaOnlineServiceAndPing);
Settings.Set(SessionSubsystemConstants::GameKeyName, FString(SessionSubsystemConstants::GameKeyValue), EOnlineDataAdvertisementType::ViaOnlineServiceAndPing);
CreateSessionCompleteHandle = SessionInterface->AddOnCreateSessionCompleteDelegate_Handle(
FOnCreateSessionCompleteDelegate::CreateUObject(this, &USessionSubsystem::HandleCreateSessionComplete));
// CreateSession rend false quand l'appel n'a meme pas pu partir : dans ce cas
// le callback ne viendra jamais, il faut donc debrancher et repondre ici.
if (!SessionInterface->CreateSession(0, NAME_GameSession, Settings))
{
SessionInterface->ClearOnCreateSessionCompleteDelegate_Handle(CreateSessionCompleteHandle);
UE_LOG(LogTemp, Error, TEXT("USessionSubsystem : CreateSession a ete refusee immediatement."));
OnHostFailed.Broadcast(LOCTEXT("HostRefused", "Could not create the session."));
}
}
void USessionSubsystem::HandleCreateSessionComplete(FName SessionName, bool bWasSuccessful)
{
SessionInterface->ClearOnCreateSessionCompleteDelegate_Handle(CreateSessionCompleteHandle);
if (!bWasSuccessful)
{
UE_LOG(LogTemp, Error, TEXT("USessionSubsystem : creation de la session [%s] echouee."), *SessionName.ToString());
OnHostFailed.Broadcast(LOCTEXT("HostFailed", "Could not create the session."));
return;
}
UE_LOG(LogTemp, Log, TEXT("USessionSubsystem : session [%s] creee pour %d joueurs."), *SessionName.ToString(), PendingMaxPlayers);
// "listen" fait de cette instance un serveur d'ecoute : l'hote joue ET
// heberge dans le meme process, ce qui est exactement le modele voulu pour
// une coop a quatre. Sans cette option la map s'ouvre en solo et les clients
// se heurtent a un port ferme.
UGameplayStatics::OpenLevelBySoftObjectPtr(GetGameInstance(), PendingHostLevel, /*bAbsolute=*/true, TEXT("listen"));
}
// ----------------------------------------------------------------------------
// Recherche
// ----------------------------------------------------------------------------
void USessionSubsystem::FindSessions(int32 MaxResults)
{
if (!SessionInterface.IsValid())
{
OnSearchFinished.Broadcast(false, 0);
return;
}
SearchSettings = MakeShared<FOnlineSessionSearch>();
SearchSettings->MaxSearchResults = FMath::Max(1, MaxResults);
SearchSettings->bIsLanQuery = IsLanMode();
// Cote Steam les parties sont des lobbies, pas des serveurs annonces : sans
// ce filtre la recherche part interroger le service de serveurs dedies et
// ne rend jamais rien. En LAN la notion n'existe pas, on l'omet.
if (!SearchSettings->bIsLanQuery)
{
SearchSettings->QuerySettings.Set(SEARCH_LOBBIES, true, EOnlineComparisonOp::Equals);
}
SearchSettings->QuerySettings.Set(SessionSubsystemConstants::GameKeyName, FString(SessionSubsystemConstants::GameKeyValue), EOnlineComparisonOp::Equals);
FindSessionsCompleteHandle = SessionInterface->AddOnFindSessionsCompleteDelegate_Handle(
FOnFindSessionsCompleteDelegate::CreateUObject(this, &USessionSubsystem::HandleFindSessionsComplete));
if (!SessionInterface->FindSessions(0, SearchSettings.ToSharedRef()))
{
SessionInterface->ClearOnFindSessionsCompleteDelegate_Handle(FindSessionsCompleteHandle);
OnSearchFinished.Broadcast(false, 0);
}
}
void USessionSubsystem::HandleFindSessionsComplete(bool bWasSuccessful)
{
SessionInterface->ClearOnFindSessionsCompleteDelegate_Handle(FindSessionsCompleteHandle);
const int32 Count = GetFoundSessionCount();
UE_LOG(LogTemp, Log, TEXT("USessionSubsystem : recherche terminee (succes %d), %d partie(s)."), bWasSuccessful ? 1 : 0, Count);
OnSearchFinished.Broadcast(bWasSuccessful, Count);
}
// ----------------------------------------------------------------------------
// Connexion
// ----------------------------------------------------------------------------
void USessionSubsystem::JoinFoundSession(int32 ResultIndex)
{
if (!SearchSettings.IsValid() || !SearchSettings->SearchResults.IsValidIndex(ResultIndex))
{
OnJoinFailed.Broadcast(LOCTEXT("JoinNoSession", "No game found to join."));
return;
}
JoinSearchResult(SearchSettings->SearchResults[ResultIndex]);
}
void USessionSubsystem::EmberJoin()
{
UE_LOG(LogTemp, Log, TEXT("USessionSubsystem : EmberJoin, recherche d'une partie..."));
// On se branche pour un seul tour : la commande enchaine recherche puis
// connexion, ce que le menu ne fait plus depuis que l'on ne rejoint que
// par invitation.
OnSearchFinished.AddDynamic(this, &USessionSubsystem::HandleExecJoinSearchFinished);
FindSessions();
}
void USessionSubsystem::HandleExecJoinSearchFinished(bool bWasSuccessful, int32 SessionCount)
{
OnSearchFinished.RemoveDynamic(this, &USessionSubsystem::HandleExecJoinSearchFinished);
if (!bWasSuccessful || SessionCount <= 0)
{
UE_LOG(LogTemp, Warning, TEXT("USessionSubsystem : EmberJoin n'a trouve aucune partie."));
return;
}
JoinFoundSession(0);
}
bool USessionSubsystem::CanInvite() const
{
if (!IsSessionActive() || IsLanMode())
{
return false;
}
const IOnlineSubsystem* Online = IOnlineSubsystem::Get();
return Online && Online->GetExternalUIInterface().IsValid();
}
bool USessionSubsystem::ShowInviteUI()
{
IOnlineSubsystem* Online = IOnlineSubsystem::Get();
if (!Online)
{
UE_LOG(LogTemp, Warning, TEXT("USessionSubsystem : pas de service en ligne, invitation impossible."));
return false;
}
const IOnlineExternalUIPtr ExternalUI = Online->GetExternalUIInterface();
if (!ExternalUI.IsValid())
{
UE_LOG(LogTemp, Warning, TEXT("USessionSubsystem : le service [%s] n'a pas d'overlay."), *Online->GetSubsystemName().ToString());
return false;
}
// Sans session ouverte, l'overlay s'affiche mais l'ami invite arrive sur
// une partie qui n'existe pas. Mieux vaut refuser franchement.
if (!IsSessionActive())
{
UE_LOG(LogTemp, Warning, TEXT("USessionSubsystem : aucune session ouverte, invitation impossible."));
return false;
}
return ExternalUI->ShowInviteUI(0, NAME_GameSession);
}
void USessionSubsystem::JoinSearchResult(const FOnlineSessionSearchResult& Result)
{
if (!SessionInterface.IsValid())
{
OnJoinFailed.Broadcast(LOCTEXT("JoinNoService", "No online service available."));
return;
}
if (!Result.IsValid())
{
OnJoinFailed.Broadcast(LOCTEXT("JoinInvalid", "This game is no longer available."));
return;
}
// Rejoindre alors qu'on heberge deja laisserait deux sessions ouvertes et
// le voyage echouerait sans message.
if (SessionInterface->GetNamedSession(NAME_GameSession) != nullptr)
{
SessionInterface->DestroySession(NAME_GameSession);
}
JoinSessionCompleteHandle = SessionInterface->AddOnJoinSessionCompleteDelegate_Handle(
FOnJoinSessionCompleteDelegate::CreateUObject(this, &USessionSubsystem::HandleJoinSessionComplete));
if (!SessionInterface->JoinSession(0, NAME_GameSession, Result))
{
SessionInterface->ClearOnJoinSessionCompleteDelegate_Handle(JoinSessionCompleteHandle);
OnJoinFailed.Broadcast(LOCTEXT("JoinRefused", "Could not join this game."));
}
}
void USessionSubsystem::HandleJoinSessionComplete(FName SessionName, EOnJoinSessionCompleteResult::Type Result)
{
SessionInterface->ClearOnJoinSessionCompleteDelegate_Handle(JoinSessionCompleteHandle);
if (Result != EOnJoinSessionCompleteResult::Success)
{
UE_LOG(LogTemp, Error, TEXT("USessionSubsystem : JoinSession a echoue (code %d)."), static_cast<int32>(Result));
// On distingue le seul cas que le joueur peut corriger lui-meme du reste,
// qui ne lui apprendrait rien.
const FText Reason = (Result == EOnJoinSessionCompleteResult::SessionIsFull)
? LOCTEXT("JoinFull", "This game is full.")
: LOCTEXT("JoinFailed", "Could not join this game.");
OnJoinFailed.Broadcast(Reason);
return;
}
// L'adresse n'est connue qu'ici : c'est le service en ligne qui la fabrique,
// et sous Steam ce n'est meme pas une IP mais un identifiant de relais.
FString ConnectString;
if (!SessionInterface->GetResolvedConnectString(SessionName, ConnectString))
{
UE_LOG(LogTemp, Error, TEXT("USessionSubsystem : adresse de l'hote introuvable."));
OnJoinFailed.Broadcast(LOCTEXT("JoinNoAddress", "Could not reach the host."));
return;
}
APlayerController* Controller = GetGameInstance() ? GetGameInstance()->GetFirstLocalPlayerController() : nullptr;
if (!Controller)
{
OnJoinFailed.Broadcast(LOCTEXT("JoinNoController", "Could not reach the host."));
return;
}
UE_LOG(LogTemp, Log, TEXT("USessionSubsystem : voyage vers [%s]."), *ConnectString);
Controller->ClientTravel(ConnectString, TRAVEL_Absolute);
}
void USessionSubsystem::HandleInviteAccepted(const bool bWasSuccessful, const int32 ControllerId, FUniqueNetIdPtr UserId, const FOnlineSessionSearchResult& InviteResult)
{
if (!bWasSuccessful)
{
UE_LOG(LogTemp, Warning, TEXT("USessionSubsystem : invitation acceptee mais illisible."));
return;
}
UE_LOG(LogTemp, Log, TEXT("USessionSubsystem : invitation acceptee, connexion en cours."));
JoinSearchResult(InviteResult);
}
// ----------------------------------------------------------------------------
// Sortie
// ----------------------------------------------------------------------------
void USessionSubsystem::LeaveSession()
{
if (!SessionInterface.IsValid() || SessionInterface->GetNamedSession(NAME_GameSession) == nullptr)
{
return;
}
// Pas de reprise apres coup ici : on quitte, point.
bCreateAfterDestroy = false;
DestroySessionCompleteHandle = SessionInterface->AddOnDestroySessionCompleteDelegate_Handle(
FOnDestroySessionCompleteDelegate::CreateUObject(this, &USessionSubsystem::HandleDestroySessionComplete));
SessionInterface->DestroySession(NAME_GameSession);
}
void USessionSubsystem::HandleDestroySessionComplete(FName SessionName, bool bWasSuccessful)
{
SessionInterface->ClearOnDestroySessionCompleteDelegate_Handle(DestroySessionCompleteHandle);
if (!bCreateAfterDestroy)
{
return;
}
bCreateAfterDestroy = false;
if (!bWasSuccessful)
{
UE_LOG(LogTemp, Error, TEXT("USessionSubsystem : impossible de detruire la session [%s], hebergement annule."), *SessionName.ToString());
OnHostFailed.Broadcast(LOCTEXT("HostStaleSession", "A previous game is still closing. Try again."));
return;
}
CreateSessionNow();
}
#undef LOCTEXT_NAMESPACE
@@ -12,6 +12,7 @@
#include "InventoryComponent.h"
#include "ItemDataAsset.h"
#include "Kismet/GameplayStatics.h"
#include "Net/UnrealNetwork.h"
#define LOCTEXT_NAMESPACE "StorageContainer"
@@ -22,6 +23,12 @@ AStorageContainer::AStorageContainer()
PrimaryActorTick.bCanEverTick = true;
PrimaryActorTick.bStartWithTickEnabled = false;
// Acteur du monde : contenu et couvercle appartiennent au serveur.
// Le UInventoryComponent, lui, se replique deja tout seul -- c'est la
// classe entiere qui le fait, coffre comme sac.
bReplicates = true;
SetReplicateMovement(false);
BaseMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("BaseMesh"));
SetRootComponent(BaseMesh);
@@ -166,15 +173,34 @@ void AStorageContainer::Interact_Implementation(AActor* Interactor)
PlayerController->OpenStorage(this);
}
void AStorageContainer::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(AStorageContainer, bOpen);
}
void AStorageContainer::SetOpen(bool bInOpen)
{
if (bOpen == bInOpen)
// Le couvercle appartient au monde : seul le serveur en decide, et la
// replication l'annonce aux autres. Un client qui forcerait bOpen verrait
// son coffre s'ouvrir seul, et le prochain OnRep le refermerait.
if (!HasAuthority() || bOpen == bInOpen)
{
return;
}
bOpen = bInOpen;
ApplyOpenState();
}
void AStorageContainer::OnRep_Open()
{
ApplyOpenState();
}
void AStorageContainer::ApplyOpenState()
{
if (USoundBase* Sound = bOpen ? OpenSound : CloseSound)
{
UGameplayStatics::PlaySoundAtLocation(this, Sound, GetActorLocation());
@@ -0,0 +1,61 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "SurvivalGameMode.h"
#include "FpsPlayerController.h"
#include "GameFramework/GameSession.h"
#include "GameFramework/PlayerState.h"
#include "SessionSubsystem.h"
#include "SurvivalGameState.h"
#include "SurvivalPlayerState.h"
ASurvivalGameMode::ASurvivalGameMode()
{
MaxCoopPlayers = USessionSubsystem::DefaultMaxPlayers;
// Les classes du framework sont cablees ici et non dans le Blueprint : ce
// sont des choix d'architecture, pas des reglages a tatonner en PIE.
GameStateClass = ASurvivalGameState::StaticClass();
PlayerStateClass = ASurvivalPlayerState::StaticClass();
PlayerControllerClass = AFpsPlayerController::StaticClass();
// Une seule map pour l'instant, donc pas de voyage sans coupure a gerer.
// A repasser a true le jour ou la partie changera de niveau : sans lui,
// chaque changement de map deconnecte puis reconnecte tous les clients.
bUseSeamlessTravel = false;
}
void ASurvivalGameMode::InitGame(const FString& MapName, const FString& Options, FString& ErrorMessage)
{
Super::InitGame(MapName, Options, ErrorMessage);
// AGameSession n'existe qu'apres le Super : c'est lui qui l'engendre.
if (GameSession)
{
GameSession->MaxPlayers = MaxCoopPlayers;
}
}
void ASurvivalGameMode::PostLogin(APlayerController* NewPlayer)
{
Super::PostLogin(NewPlayer);
// Trace volontairement conservee : c'est la preuve la plus rapide qu'une
// connexion a bien abouti, et le premier endroit ou regarder quand un ami
// dit "ca tourne mais je ne te vois pas".
const APlayerState* State = NewPlayer ? NewPlayer->GetPlayerState<APlayerState>() : nullptr;
UE_LOG(LogTemp, Log, TEXT("ASurvivalGameMode : arrivee de [%s], %d joueur(s) sur %d."),
State ? *State->GetPlayerName() : TEXT("inconnu"),
GetNumPlayers(),
MaxCoopPlayers);
}
void ASurvivalGameMode::Logout(AController* Exiting)
{
const APlayerState* State = Exiting ? Exiting->GetPlayerState<APlayerState>() : nullptr;
UE_LOG(LogTemp, Log, TEXT("ASurvivalGameMode : depart de [%s]."),
State ? *State->GetPlayerName() : TEXT("inconnu"));
Super::Logout(Exiting);
}
@@ -4,18 +4,64 @@
#include "SurvivalStatsComponent.h"
#include "Engine/World.h"
#include "Net/UnrealNetwork.h"
#include "TimerManager.h"
USurvivalStatsComponent::USurvivalStatsComponent()
{
// Pas de Tick : tout passe par un timer a basse frequence.
PrimaryComponentTick.bCanEverTick = false;
SetIsReplicatedByDefault(true);
}
void USurvivalStatsComponent::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
// COND_OwnerOnly ici, contrairement a l'inventaire : ces jauges n'existent
// que sur un pawn, qui appartient bien a une connexion -- la condition
// fonctionne donc. Et la faim d'un coequipier ne regarde personne d'autre
// que lui. Le jour ou une barre de vie s'affichera au-dessus des allies,
// c'est Health seul qu'il faudra ouvrir a tous.
DOREPLIFETIME_CONDITION(USurvivalStatsComponent, Health, COND_OwnerOnly);
DOREPLIFETIME_CONDITION(USurvivalStatsComponent, Hunger, COND_OwnerOnly);
DOREPLIFETIME_CONDITION(USurvivalStatsComponent, Thirst, COND_OwnerOnly);
DOREPLIFETIME_CONDITION(USurvivalStatsComponent, bDead, COND_OwnerOnly);
}
void USurvivalStatsComponent::OnRep_Stats()
{
OnStatsChanged.Broadcast();
}
void USurvivalStatsComponent::OnRep_Dead()
{
OnStatsChanged.Broadcast();
// bDead ne repasse jamais a vrai sans un nouveau pawn : apres un respawn
// c'est un AUTRE composant qui arrive, plein et vivant. Pas de risque de
// rediffuser une mort deja traitee.
if (bDead)
{
OnDied.Broadcast();
}
}
void USurvivalStatsComponent::BeginPlay()
{
Super::BeginPlay();
// Un client ne simule rien : il recevra les valeurs par replication.
// Sans cette garde, chaque client ferait descendre SA copie des jauges en
// plus de celle du serveur, et les barres sauteraient a chaque OnRep --
// un scintillement qu'on chercherait longtemps.
const AActor* Owner = GetOwner();
if (!Owner || !Owner->HasAuthority())
{
return;
}
Health = MaxHealth;
Hunger = MaxHunger;
Thirst = MaxThirst;
@@ -91,6 +137,15 @@ void USurvivalStatsComponent::UpdateStats()
void USurvivalStatsComponent::ApplyRestore(float HealthAmount, float HungerAmount, float ThirstAmount)
{
// Manger se decide sur le serveur (AFpsPlayer::Server_UseItem). Une
// restauration jouee en local remonterait la barre du client, que le
// prochain OnRep ferait redescendre : un clignotement pour rien.
const AActor* Owner = GetOwner();
if (!Owner || !Owner->HasAuthority())
{
return;
}
if (bDead)
{
return;
@@ -105,6 +160,13 @@ void USurvivalStatsComponent::ApplyRestore(float HealthAmount, float HungerAmoun
void USurvivalStatsComponent::ApplyDamage(float Amount)
{
// Meme regle que ApplyRestore : les degats appartiennent a l'autorite.
const AActor* Owner = GetOwner();
if (!Owner || !Owner->HasAuthority())
{
return;
}
if (bDead || Amount <= 0.f)
{
return;
+30 -3
View File
@@ -367,6 +367,8 @@ private:
* foyer. Se base sur le point de vue reel du pawn, donc sur ce qu'il voit a
* l'ecran, et non sur la position de son corps.
*/
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
int32 ResolveAimedSlot(const AActor* Interactor) const;
/** Decide ce que ferait un appui sur Interagir, sans rien modifier. */
@@ -455,8 +457,17 @@ private:
/** Inventaire du pawn qui interagit, ou nullptr. */
static UInventoryComponent* GetInventoryOf(AActor* Interactor);
/** Etat courant de chaque emplacement de cuisson. Toujours dimensionne a MaxCookingSlots. */
UPROPERTY(Transient)
/**
* Etat courant de chaque emplacement de cuisson. Toujours dimensionne a
* MaxCookingSlots.
*
* Replique : c'est ce qui fait changer le mesh de la nourriture sous les
* yeux de tous. Le FTimerHandle que porte chaque slot n'est pas une
* UPROPERTY et ne part donc pas sur le reseau -- c'est voulu, la cuisson se
* compte uniquement sur le serveur et les clients n'en voient que le
* resultat.
*/
UPROPERTY(Transient, ReplicatedUsing = OnRep_Slots)
TArray<FCookingSlotState> Slots;
/**
@@ -464,7 +475,12 @@ private:
* qui brule ; quand il est consomme, tout le monde avance d'un cran et la
* derniere buche disparait du foyer.
*/
UPROPERTY(Transient)
/**
* Replique : le nombre de buches visibles EST la jauge de carburant, c'est
* la seule facon pour un joueur de savoir combien il reste de feu. Les
* secondes restantes partent avec, mais seul le serveur les decompte.
*/
UPROPERTY(Transient, ReplicatedUsing = OnRep_Fuel)
TArray<FFuelSlotState> Fuel;
FTimerHandle FuelTimer;
@@ -477,5 +493,16 @@ private:
UPROPERTY(Transient)
TArray<TObjectPtr<UStaticMeshComponent>> FuelIndicators;
UPROPERTY(ReplicatedUsing = OnRep_IsLit)
bool bIsLit = false;
/** Le contenu du grill a change cote serveur : on repose tous les meshes. */
UFUNCTION()
void OnRep_Slots();
UFUNCTION()
void OnRep_Fuel();
UFUNCTION()
void OnRep_IsLit();
};
@@ -117,6 +117,14 @@ public:
*
* @return true si la fabrication a eu lieu.
*/
/**
* Fabrique la recette. Chez un client, envoie la demande au serveur et rend
* true : le resultat redescendra par la replication de l'inventaire.
*
* Le serveur refait TOUJOURS CanCraft de son cote, avec sa propre liste de
* postes accessibles -- celle que le controller tient a jour par
* Client_OpenCraftingAtStation et Server_ReleaseStation.
*/
UFUNCTION(BlueprintCallable, Category = "Crafting")
bool Craft(UCraftingRecipeDataAsset* Recipe);
@@ -167,6 +175,10 @@ protected:
TObjectPtr<UInventoryComponent> Inventory;
private:
/** La fabrication demandee par un client, arbitree par le serveur. */
UFUNCTION(Server, Reliable)
void Server_Craft(UCraftingRecipeDataAsset* Recipe);
/**
* Combien de postes de chaque type sont a portee, et non un simple
* "present / absent" -- voir AddAvailableStation.
+45
View File
@@ -76,6 +76,8 @@ public:
// Called every frame
virtual void Tick(float DeltaTime) override;
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
// Called to bind functionality to input
virtual void SetupPlayerInputComponent(class UInputComponent* PlayerInputComponent) override;
@@ -487,6 +489,47 @@ protected:
void UseSelectedItem();
private:
// ------------------------------------------------------------------
// Réseau
//
// Jeter et utiliser engendrent ou détruisent des choses dans le monde :
// ce sont des actions d'autorité. Le routage vit dans les fonctions
// publiques elles-mêmes, pour que ni l'UI ni l'input n'aient à savoir
// s'ils tournent chez l'hôte ou chez un client.
// ------------------------------------------------------------------
UFUNCTION(Server, Reliable)
void Server_DropFromInventory(UInventoryComponent* Inventory, int32 SlotIndex, int32 Quantity);
/**
* Le slot est passé explicitement : SelectedHotbarIndex n'est PAS répliqué,
* c'est une intention purement locale. Le serveur ne peut donc pas deviner
* ce que le joueur avait en main.
*/
UFUNCTION(Server, Reliable)
void Server_UseItem(int32 SlotIndex);
/** L'utilisation réelle, côté autorité. */
void PerformUseItem(int32 SlotIndex);
/**
* Point d'entrée unique du sprint : applique en local ET prévient le serveur.
*
* Sans le second, le serveur croirait le client à `WalkSpeed` et corrigerait
* sa position à chaque pas c'est le rubber-banding classique, et il ne se
* voit que sur une machine distante, jamais chez l'hôte.
*/
void SetSprinting(bool bNewSprinting);
UFUNCTION(Server, Reliable)
void Server_SetSprinting(bool bNewSprinting);
/** La vitesse suit l'état, y compris sur les pawns des autres joueurs. */
UFUNCTION()
void OnRep_IsSprinting();
void ApplySprintSpeed();
/** Fait avancer toutes les animations de caméra (intégration uniquement). */
void UpdateCameraEffects(float DeltaTime);
@@ -542,6 +585,8 @@ private:
/** Vitesse verticale mémorisée pendant la chute, pour calibrer l'atterrissage. */
float LastFallSpeed = 0.f;
/** Répliqué pour que les autres joueurs voient la bonne vitesse et la bonne anim. */
UPROPERTY(ReplicatedUsing = OnRep_IsSprinting)
bool bIsSprinting = false;
/** Évite de spammer le log si IA_Look est mal configurée. */
@@ -103,6 +103,22 @@ public:
UFUNCTION(BlueprintCallable, Category = "UI|Reglages")
void OpenSettings();
/**
* Ouvre le panneau d'invitation de l'overlay Steam.
*
* Passe par le controller et non directement du widget au sous-systeme :
* inviter est une action du JOUEUR LOCAL, et le controller est justement
* l'objet qui le represente. Un widget qui irait chercher un
* GameInstanceSubsystem tout seul se brancherait sur le mauvais joueur le
* jour ou l'ecran partage existera.
*/
UFUNCTION(BlueprintCallable, Category = "Coop")
void ShowInviteFriends();
/** Faux quand Steam est absent ou qu'aucune session n'a pu etre creee. */
UFUNCTION(BlueprintPure, Category = "Coop")
bool CanInviteFriends() const;
protected:
virtual void BeginPlay() override;
virtual void OnPossess(APawn* InPawn) override;
@@ -389,9 +405,68 @@ private:
UFUNCTION()
void HandlePawnDied();
/** Detruit le cadavre et demande un nouveau pawn au GameMode. */
/**
* Detruit le cadavre et demande un nouveau pawn au GameMode.
*
* Le GameMode n'existe QUE sur le serveur : chez un client
* GetAuthGameMode() rend nullptr, toujours. Un client route donc par
* Server_RequestRespawn, et ne garde en local que son fondu de retour --
* c'est de l'affichage, ca lui appartient.
*/
void RespawnPlayer();
UFUNCTION(Server, Reliable)
void Server_RequestRespawn();
// ------------------------------------------------------------------
// Ouverture d'ecran demandee par un acteur du monde
//
// Interact_Implementation s'execute sur le SERVEUR depuis le passage en
// coop. Or ces acteurs ouvrent une interface, qui n'existe que sur la
// machine du joueur : l'appel doit donc repartir vers le client par une
// RPC Client_. Sans elles, le serveur cree des widgets dans le vide et
// l'ecran du joueur ne s'ouvre jamais.
//
// L'etat "devant quel poste je suis" est tenu des DEUX cotes : le client en
// a besoin pour filtrer sa liste de recettes, le serveur pour valider le
// craft. Ne le tenir que d'un cote laisserait soit une liste vide, soit un
// etabli utilisable depuis la foret.
// ------------------------------------------------------------------
UFUNCTION(Client, Reliable)
void Client_OpenCraftingAtStation(ECraftingStation Station);
UFUNCTION(Client, Reliable)
void Client_OpenStorage(AStorageContainer* Container);
/** Le joueur a referme son ecran : le serveur reprend le poste accorde. */
UFUNCTION(Server, Reliable)
void Server_ReleaseStation(ECraftingStation Station);
/** Le joueur a referme le coffre : le serveur rabat le couvercle. */
UFUNCTION(Server, Reliable)
void Server_ReleaseStorage(AStorageContainer* Container);
/** Accorde le poste sur le composant du pawn et note lequel. */
void GrantStation(ECraftingStation Station);
// ------------------------------------------------------------------
// Pseudo
//
// Le serveur ne peut PAS deviner le pseudo Steam d'un client : l'API
// d'identite ne connait que l'utilisateur local de chaque machine. C'est
// donc au client de l'annoncer. Sans ca, le moteur laisse le nom par
// defaut du fichier de configuration -- on lit "Player" au-dessus des
// quatre joueurs, ce qui se remarque tout de suite.
// ------------------------------------------------------------------
/** Le pseudo de l'utilisateur Steam local, ou une chaine vide si indisponible. */
FString ResolveLocalPlayerNickname() const;
/** Ecrit le pseudo sur le PlayerState, qui le replique a tout le monde. */
UFUNCTION(Server, Reliable)
void Server_SetPlayerNickname(const FString& Nickname);
FTimerHandle RespawnTimerHandle;
/** Surveillance de l'eloignement, actif seulement pendant qu'un coffre est ouvert. */
@@ -40,7 +40,14 @@ public:
virtual void TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) override;
/** Declenche l'interaction sur l'objet vise, s'il y en a un. */
/**
* Declenche l'interaction sur l'objet vise, s'il y en a un.
*
* Le trace, lui, reste PUREMENT LOCAL : chacun vise chez soi, a sa propre
* frequence, sans le moindre aller-retour reseau -- le prompt doit s'afficher
* a l'instant ou l'on vise, pas 60 ms plus tard. Seule l'action part au
* serveur, qui est le seul a pouvoir modifier le monde.
*/
UFUNCTION(BlueprintCallable, Category = "Interaction")
void TryInteract();
@@ -93,7 +100,36 @@ protected:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interaction")
bool bDrawDebug = false;
/**
* Marge accordee par le serveur quand il verifie la distance annoncee par un
* client. 1.0 refuserait des interactions parfaitement legitimes : entre le
* moment ou le client vise et celui ou son message arrive, son personnage a
* bouge, et la position que le serveur connait de lui a jusqu'a un aller-retour
* de retard.
*
* Ce n'est PAS de l'anti-triche -- coop entre amis, on ne paie pas ce prix --
* mais un garde-fou contre un client desynchronise qui ouvrirait un coffre a
* l'autre bout de la carte.
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interaction|Reseau", meta = (ClampMin = "1.0"))
float ServerRangeTolerance = 2.f;
private:
/**
* L'interaction demandee par un client, executee par le serveur.
*
* Reliable : rater un ramassage parce qu'un paquet s'est perdu serait
* incomprehensible pour le joueur. Le debit est nul de toute facon, une
* interaction est un evenement rare a l'echelle du reseau.
*/
UFUNCTION(Server, Reliable)
void Server_Interact(AActor* Target);
/**
* L'execution reelle, cote autorite. Revalide la cible : le client peut
* l'avoir vue valide alors qu'un autre joueur venait de la ramasser.
*/
void PerformInteract(AActor* Target);
/** Relance le trace et met a jour FocusedActor. */
void UpdateFocus();
@@ -67,6 +67,8 @@ public:
virtual void InitializeComponent() override;
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
/**
* Ajoute des objets, en remplissant d'abord les piles entamees.
* @param RemainingUses charges de l'exemplaire ajoute ; -1 signifie "plein".
@@ -274,11 +276,89 @@ protected:
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Inventaire|Barre rapide", meta = (ClampMin = "0"))
int32 HotbarSlotCount = 10;
/** Toujours dimensionne a GetTotalSlotCount(). */
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Inventaire")
/**
* Toujours dimensionne a GetTotalSlotCount().
*
* Replique a TOUT LE MONDE et non au seul proprietaire, pour deux raisons.
*
* Technique d'abord : les conditions de GetLifetimeReplicatedProps sont
* mises en cache par CLASSE, pas par instance. Un COND_OwnerOnly viserait
* donc aussi le composant d'un coffre -- or un coffre n'appartient a aucune
* connexion, et sa grille n'arriverait chez personne. Le meme composant sert
* le sac et le contenant, c'est une decision d'architecture qu'on ne veut pas
* defaire pour un drapeau de replication.
*
* De fond ensuite : les coffres sont a acces simultane, donc leur contenu
* DOIT arriver a tous ceux qui l'ouvrent. Et voir le sac d'un coequipier n'a
* rien d'un probleme dans une coop entre amis sans anti-triche -- c'est meme
* ce qui rendra un futur echange d'objets gratuit.
*
* Cout reel : 4 joueurs x 30 slots de trois champs, envoyes uniquement quand
* ils changent, et delta-compresses. Negligeable.
*/
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, ReplicatedUsing = OnRep_Slots, Category = "Inventaire")
TArray<FInventorySlot> Slots;
/**
* Le contenu vient d'arriver du serveur : on rediffuse le delegue habituel.
*
* C'est LA ligne qui recable toute l'interface d'un coup. Grille, barre
* rapide, panneau de detail et coffres sont deja abonnes a
* OnInventoryChanged et ne font aucun Tick -- ils n'ont donc pas une seule
* ligne a changer pour devenir reseau.
*/
UFUNCTION()
void OnRep_Slots();
private:
// ------------------------------------------------------------------
// Reseau
//
// Le routage vit DANS les fonctions publiques et non chez leurs appelants :
// une case d'inventaire appelle MoveItem() sans savoir si elle tourne chez
// l'hote ou chez un client, et c'est tres bien ainsi. Ajouter le test
// d'autorite dans chaque widget serait dix occasions de l'oublier.
//
// Pas de prediction locale : le client envoie et attend OnRep_Slots. Sur un
// relais Steam l'aller-retour est de quelques dizaines de millisecondes,
// invisible pour un glisser-deposer -- alors qu'une prediction fausse
// donnerait une pile qui saute, symptome bien plus desagreable.
// ------------------------------------------------------------------
/** Ce composant peut-il modifier son contenu ici et maintenant. */
bool HasInventoryAuthority() const;
/**
* Par quel composant faire transiter une RPC serveur.
*
* Renvoie celui des deux qui appartient au pawn du joueur LOCAL : un coffre
* n'appartient a aucune connexion, et une RPC emise depuis lui serait
* silencieusement jetee par le moteur. C'est le piege numero un des
* transferts vers un conteneur.
*/
static UInventoryComponent* FindNetProxy(UInventoryComponent* A, UInventoryComponent* B);
UFUNCTION(Server, Reliable)
void Server_TransferSlot(UInventoryComponent* From, int32 FromIndex, UInventoryComponent* To, int32 ToIndex, int32 Quantity);
UFUNCTION(Server, Reliable)
void Server_QuickTransferSlot(UInventoryComponent* From, int32 FromIndex, UInventoryComponent* To);
/**
* From est passe explicitement et n'est PAS "this" cote serveur : la RPC
* transite par l'inventaire du joueur local, qui peut tres bien etre la
* DESTINATION -- c'est le cas du bouton "Tout prendre" d'un coffre. Deduire
* la source du recepteur viderait le sac du joueur dans lui-meme.
*/
UFUNCTION(Server, Reliable)
void Server_TransferAllTo(UInventoryComponent* From, UInventoryComponent* To, int32 FirstIndex);
UFUNCTION(Server, Reliable)
void Server_ConsumeUse(int32 SlotIndex);
/** Les regles de deplacement, une fois l'autorite acquise. */
static bool ApplyTransferSlot(UInventoryComponent* From, int32 FromIndex, UInventoryComponent* To, int32 ToIndex, int32 Quantity);
/** Redimensionne le tableau si la taille cible a change. */
void EnsureSlotCount();
@@ -21,6 +21,12 @@ class USettingsMenuWidget;
enum class EMenuPendingAction : uint8
{
None,
/**
* Le fondu part AVANT la creation de la session, qui est asynchrone. La map
* n'est donc chargee qu'a la reponse du service en ligne -- ou tout de suite
* si celui-ci refuse, puisqu'un echec reseau ne doit pas empecher de jouer.
*/
StartGame
};
@@ -38,9 +44,21 @@ class SURVIVAL_PROJET_API AMainMenuPlayerController : public APlayerController
public:
AMainMenuPlayerController();
/** Fondu au noir, puis chargement de la map de jeu. */
/**
* Fondu au noir, creation de la session, puis ouverture de la map en
* serveur d'ecoute.
*
* Il n'existe qu'UN seul bouton pour lancer une partie : toute partie est
* une partie en ligne, meme jouee seul. Distinguer "solo" et "heberger"
* obligerait le joueur a decider avant de savoir si un ami se connectera,
* et surtout a relancer sa partie pour changer d'avis.
*
* On ne rejoint jamais depuis ce menu : l'arrivee se fait uniquement par
* invitation Steam, geree par USessionSubsystem quelle que soit la map
* affichee a cet instant.
*/
UFUNCTION(BlueprintCallable, Category = "Menu")
void StartSoloGame();
void StartGame();
/** Ouvre la demande de confirmation. Ne quitte rien par elle-meme. */
UFUNCTION(BlueprintCallable, Category = "Menu")
@@ -125,6 +143,25 @@ private:
UFUNCTION()
void HandleSettingsCloseRequested();
/**
* La session n'a pas pu etre creee -- Steam eteint, hors ligne, service en
* panne. On charge la map QUAND MEME.
*
* C'est un choix delibere : un joueur qui clique Jouer veut jouer. Le rendre
* a son menu avec un message d'erreur reseau alors que rien ne l'empeche de
* jouer seul serait une punition pour une panne qui n'est pas la sienne. Il
* perd seulement la possibilite d'inviter, et le bouton Inviter le dira.
*/
UFUNCTION()
void HandleHostFailed(FText Reason);
/**
* Echec de connexion sur invitation : on remonte le fondu et on rend le menu.
* Ici pas de repli possible, la partie visee est celle d'un ami.
*/
UFUNCTION()
void HandleJoinFailed(FText Reason);
/** Ferme le jeu sans rien demander. */
void CloseGame();
+8 -12
View File
@@ -28,7 +28,14 @@ public:
virtual void NativeConstruct() override;
protected:
/** Lance la partie solo. Nomme exactement "PlayButton" dans le WBP. */
/**
* Lance la partie. Nomme exactement "PlayButton" dans le WBP.
*
* Un seul bouton, et toute partie est en ligne : il n'y a ni "Heberger" ni
* "Rejoindre". On arrive chez un ami par invitation Steam et par rien
* d'autre, ce qui evite au joueur de choisir un mode avant de savoir si
* quelqu'un le rejoindra.
*/
UPROPERTY(meta = (BindWidget))
TObjectPtr<UButton> PlayButton;
@@ -39,17 +46,6 @@ protected:
UPROPERTY(meta = (BindWidgetOptional))
TObjectPtr<UButton> SettingsButton;
/**
* Coop a venir. BindWidgetOptional et non BindWidget : tu peux dessiner ton
* WBP_MainMenu sans eux aujourd'hui, le Blueprint compilera quand meme.
* S'ils existent, ils sont grises automatiquement.
*/
UPROPERTY(meta = (BindWidgetOptional))
TObjectPtr<UButton> HostButton;
UPROPERTY(meta = (BindWidgetOptional))
TObjectPtr<UButton> JoinButton;
private:
UFUNCTION()
void HandlePlayClicked();
@@ -45,6 +45,18 @@ protected:
UPROPERTY(meta = (BindWidgetOptional))
TObjectPtr<UButton> SettingsButton;
/**
* Ouvre le panneau d'invitation de l'overlay Steam. C'est la SEULE facon de
* faire venir un ami : il n'existe ni navigateur de parties, ni bouton
* Rejoindre dans le menu principal.
*
* Grise tout seul quand l'invitation est impossible -- Steam absent, ou
* partie lancee alors qu'il l'etait. Un bouton qui ne repond pas au clic
* passe pour un bug ; un bouton grise se comprend tout seul.
*/
UPROPERTY(meta = (BindWidgetOptional))
TObjectPtr<UButton> InviteButton;
private:
UFUNCTION()
void HandleResumeClicked();
@@ -54,4 +66,7 @@ private:
UFUNCTION()
void HandleQuitToMenuClicked();
UFUNCTION()
void HandleInviteClicked();
};
+33 -5
View File
@@ -27,10 +27,15 @@ class SURVIVAL_PROJET_API APickupItem : public AActor, public IInteractable
public:
APickupItem();
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
/**
* Configure un pickup engendre a l'execution (objet jete au sol).
* A appeler entre SpawnActorDeferred() et FinishSpawning() pour que
* OnConstruction ait deja les bonnes donnees et pose le bon mesh.
*
* A n'appeler que sur le SERVEUR : c'est lui qui engendre les acteurs du
* monde, les clients recoivent l'acteur deja configure par replication.
*/
UFUNCTION(BlueprintCallable, Category = "Item")
void InitPickup(UItemDataAsset* InItemData, int32 InQuantity, int32 InRemainingUses = -1);
@@ -62,21 +67,44 @@ protected:
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Item")
TObjectPtr<USphereComponent> InteractionSphere;
/** Quel objet ce pickup represente. A assigner sur chaque instance. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Item")
/**
* Quel objet ce pickup represente. A assigner sur chaque instance.
*
* ReplicatedUsing et non simplement Replicated : chez un client, l'acteur
* est construit AVANT que ses proprietes arrivent. OnConstruction s'execute
* donc avec ItemData a nullptr et ne pose aucun mesh -- l'objet resterait
* invisible pour tout le monde sauf l'hote. C'est OnRep qui repose le visuel
* au moment ou la donnee arrive vraiment.
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, ReplicatedUsing = OnRep_ItemData, Category = "Item")
TObjectPtr<UItemDataAsset> ItemData;
/** Combien d'exemplaires ce tas contient. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Item", meta = (ClampMin = "1"))
/**
* Combien d'exemplaires ce tas contient.
* Replique parce que le prompt l'affiche ("Ramasser Branche x3") et qu'un
* ramassage partiel, inventaire plein, le fait diminuer sous les yeux de tous.
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Replicated, Category = "Item")
int32 Quantity = 1;
/**
* Charges restantes pour un objet a usages multiples.
* -1 = plein, ce qui est le cas de tout objet pose a la main dans le niveau.
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Item")
UPROPERTY(EditAnywhere, BlueprintReadWrite, Replicated, Category = "Item")
int32 RemainingUses = -1;
/** Repose le mesh quand la donnee de l'objet arrive chez un client. */
UFUNCTION()
void OnRep_ItemData();
/**
* Applique le visuel decrit par l'ItemData. Partage par OnConstruction et
* OnRep_ItemData : deux copies finiraient par diverger, et le symptome
* serait un objet correct dans l'editeur mais nu en jeu.
*/
void ApplyItemVisuals();
/**
* Reprend automatiquement le WorldMesh de l'ItemData dans l'editeur.
* Decoche pour imposer un mesh different sur une instance precise.
@@ -0,0 +1,88 @@
// Fill out your copyright notice in the Description page of Project Settings.
#pragma once
#include "CoreMinimal.h"
#include "Components/WidgetComponent.h"
#include "PlayerNameplateComponent.generated.h"
class APlayerState;
/**
* L'etiquette de pseudo posee au-dessus d'un joueur.
*
* Herite de UWidgetComponent et le configure en espace ECRAN : c'est ce qui la
* fait toujours faire face a la camera, gratuitement et exactement, sans le
* moindre calcul d'orientation par frame. En espace Monde il faudrait la
* reorienter soi-meme a chaque image, et elle serait occultee par le decor --
* un pseudo qui disparait derriere un caillou n'aide personne.
*
* Contrepartie assumee de l'espace ecran : la taille ne diminue pas avec la
* distance. C'est le comportement des jeux coop du genre, et c'est justement
* pourquoi une distance de disparition est indispensable -- sans elle, quatre
* pseudos resteraient lisibles a l'autre bout de la carte.
*
* Le composant ne replique RIEN : le pseudo vit deja sur le PlayerState, qui
* est replique par le moteur. Ajouter une propriete ici enverrait deux fois la
* meme chaine sur le reseau.
*/
UCLASS(ClassGroup = (Survival), meta = (BlueprintSpawnableComponent))
class SURVIVAL_PROJET_API UPlayerNameplateComponent : public UWidgetComponent
{
GENERATED_BODY()
public:
UPlayerNameplateComponent();
virtual void BeginPlay() override;
virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override;
protected:
/**
* Au-dela de cette distance, l'etiquette disparait. 2500 cm = 25 m, soit un
* peu plus que la portee a laquelle on distingue un coequipier dans un
* decor dense.
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Nameplate", meta = (ClampMin = "100", Units = "cm"))
float MaxDrawDistance = 2500.f;
/**
* Periode de reevaluation, en secondes.
*
* Un timer et pas un Tick : la distance a la camera n'a aucune raison
* d'etre recalculee 120 fois par seconde pour une etiquette qui apparait ou
* disparait une fois toutes les dix secondes. Quatre joueurs a 120 ips
* feraient 480 calculs de distance par seconde pour rien.
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Nameplate", meta = (ClampMin = "0.05", Units = "s"))
float RefreshInterval = 0.25f;
/**
* Cacher l'etiquette de son PROPRE personnage.
*
* Vrai par defaut : en vue premiere personne on ne voit jamais son propre
* corps, mais une etiquette en espace ecran, elle, se projetterait en plein
* milieu du viseur.
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Nameplate")
bool bHideOnOwningPlayer = true;
private:
/** Distance a la camera locale, puis mise a jour du texte si besoin. */
void Refresh();
/** Le PlayerState du pawn porteur, ou nullptr tant qu'il n'est pas arrive. */
APlayerState* GetOwnerPlayerState() const;
FTimerHandle RefreshTimer;
/**
* Dernier pseudo reellement ecrit dans le widget.
*
* Le PlayerState arrive APRES le pawn chez un client : la premiere passe
* trouve donc un nom vide, et il faut continuer a regarder. Comparer plutot
* que reecrire evite de reconstruire la mise en page du Text Block quatre
* fois par seconde pour une chaine identique.
*/
FString LastShownName;
};
@@ -0,0 +1,37 @@
// Fill out your copyright notice in the Description page of Project Settings.
#pragma once
#include "CoreMinimal.h"
#include "Blueprint/UserWidget.h"
#include "PlayerNameplateWidget.generated.h"
class UTextBlock;
/**
* L'etiquette flottante au-dessus d'un joueur : son pseudo Steam.
*
* Volontairement passive -- elle ne va chercher aucune donnee et ne s'abonne a
* rien. C'est UPlayerNameplateComponent qui la nourrit, parce que lui seul sait
* DE QUEL joueur il s'agit : un widget pose sur le pawn d'autrui n'a aucun moyen
* fiable de remonter jusqu'a son PlayerState sans supposer une hierarchie.
*
* Classe Abstract comme les autres widgets du projet : on cree un
* WBP_PlayerNameplate qui en herite, le C++ porte la logique et le Blueprint la
* mise en page.
*/
UCLASS(Abstract)
class SURVIVAL_PROJET_API UPlayerNameplateWidget : public UUserWidget
{
GENERATED_BODY()
public:
/** Ecrit le pseudo affiche. Sans effet si le Text Block n'existe pas. */
UFUNCTION(BlueprintCallable, Category = "Nameplate")
void SetPlayerName(const FText& InName);
protected:
/** A nommer exactement "PlayerNameText" dans le WBP. */
UPROPERTY(meta = (BindWidget))
TObjectPtr<UTextBlock> PlayerNameText;
};
@@ -0,0 +1,198 @@
// Fill out your copyright notice in the Description page of Project Settings.
#pragma once
#include "CoreMinimal.h"
#include "Interfaces/OnlineSessionInterface.h"
#include "Subsystems/GameInstanceSubsystem.h"
#include "SessionSubsystem.generated.h"
/**
* Raison d'un echec, deja redigee pour etre affichee telle quelle.
*
* Il n'existe volontairement PAS de delegue de succes symetrique : quand
* l'hebergement ou la connexion reussit, la map change et tout ce qui ecoutait
* est detruit dans la seconde. Un delegue de succes serait donc soit jamais lu,
* soit lu par un widget qui n'existe deja plus.
*/
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnSessionError, FText, Reason);
/** Fin d'une recherche. SessionCount vaut 0 si personne n'heberge. */
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnSessionSearchFinished, bool, bWasSuccessful, int32, SessionCount);
/**
* Hebergement et connexion : creer une partie, la trouver, la rejoindre.
*
* GameInstanceSubsystem et pas un composant : une session doit survivre au
* changement de map, puisque c'est justement elle qui le provoque. Le critere
* habituel du projet -- "si le pawn meurt, est-ce que ca doit survivre ?" --
* la place au meme endroit que les reglages.
*
* Le C++ ici ne parle JAMAIS de Steam. Tout passe par IOnlineSubsystem, dont
* l'implementation est choisie par DefaultEngine.ini. Consequence directe : le
* meme code marche en LAN via OnlineSubsystemNULL, ce qui permet de tester a
* deux instances sur la meme machine sans client Steam.
*/
UCLASS()
class SURVIVAL_PROJET_API USessionSubsystem : public UGameInstanceSubsystem
{
GENERATED_BODY()
public:
/** Coop a quatre : la valeur de reference du projet. */
static constexpr int32 DefaultMaxPlayers = 4;
virtual void Initialize(FSubsystemCollectionBase& Collection) override;
virtual void Deinitialize() override;
/**
* Cree la session puis ouvre la map en serveur d'ecoute.
*
* C'est le sous-systeme qui charge la map et non l'appelant, parce que la
* creation est ASYNCHRONE : entre le clic et la reponse de Steam il peut
* s'ecouler une seconde, et ouvrir la map avant d'avoir la session donnerait
* une partie que personne ne peut rejoindre.
*
* En cas d'echec, rien ne bouge et OnHostFailed est diffuse.
*
* MaxPlayers n'a volontairement PAS de valeur par defaut : UHT ne sait pas
* resoudre une constante qualifiee dans une signature UFUNCTION, et ecrire
* "= 4" ici recreerait le second nombre a tenir en phase avec le GameMode.
* Les appelants passent USessionSubsystem::DefaultMaxPlayers.
*/
UFUNCTION(BlueprintCallable, Category = "Session")
void HostSession(const TSoftObjectPtr<UWorld>& Level, int32 MaxPlayers);
/** Cherche les parties joignables. Le resultat arrive par OnSearchFinished. */
UFUNCTION(BlueprintCallable, Category = "Session")
void FindSessions(int32 MaxResults = 20);
/**
* Rejoint un resultat de la derniere recherche, puis voyage vers l'hote.
* Un index hors bornes diffuse OnJoinFailed plutot que de ne rien faire.
*/
UFUNCTION(BlueprintCallable, Category = "Session")
void JoinFoundSession(int32 ResultIndex = 0);
/**
* Cherche puis rejoint la premiere partie trouvee, en une commande console.
*
* Aucun bouton ne mene ici : on ne rejoint une partie QUE par invitation
* Steam. Cette commande n'existe que pour pouvoir tester a deux instances
* sur une seule machine, ou aucune invitation n'est possible. La taper dans
* la console (touche ~) sur la seconde fenetre.
*/
UFUNCTION(Exec, Category = "Session")
void EmberJoin();
/**
* Ouvre le panneau d'invitation de l'overlay Steam.
*
* @return false si l'overlay est indisponible -- pas de service en ligne,
* pas de session, ou overlay desactive cote client Steam.
*/
UFUNCTION(BlueprintCallable, Category = "Session")
bool ShowInviteUI();
/**
* Peut-on inviter maintenant. Sert a griser le bouton plutot qu'a le laisser
* cliquer dans le vide : une partie lancee alors que Steam etait absent
* tourne tres bien, mais n'a personne a qui s'annoncer.
*/
UFUNCTION(BlueprintPure, Category = "Session")
bool CanInvite() const;
/**
* Quitte la session courante. A appeler en revenant au menu principal :
* une session laissee derriere soi empeche d'en creer une nouvelle, et
* laisse surtout ta partie annoncee a tes amis alors qu'elle n'existe plus.
*/
UFUNCTION(BlueprintCallable, Category = "Session")
void LeaveSession();
/** Vrai si une session est ouverte, qu'on l'heberge ou qu'on l'ait rejointe. */
UFUNCTION(BlueprintPure, Category = "Session")
bool IsSessionActive() const;
/**
* Nom du service en ligne reellement charge : "Steam", ou "NULL" en LAN.
* Sert a l'UI et surtout au diagnostic -- voir "NULL" alors qu'on attendait
* Steam est le symptome le plus frequent, et il se lit en une seconde.
*/
UFUNCTION(BlueprintPure, Category = "Session")
FString GetOnlineServiceName() const;
/** Combien de resultats la derniere recherche a rapportes. */
UFUNCTION(BlueprintPure, Category = "Session")
int32 GetFoundSessionCount() const;
UPROPERTY(BlueprintAssignable, Category = "Session")
FOnSessionError OnHostFailed;
UPROPERTY(BlueprintAssignable, Category = "Session")
FOnSessionError OnJoinFailed;
UPROPERTY(BlueprintAssignable, Category = "Session")
FOnSessionSearchFinished OnSearchFinished;
private:
void HandleCreateSessionComplete(FName SessionName, bool bWasSuccessful);
void HandleFindSessionsComplete(bool bWasSuccessful);
void HandleJoinSessionComplete(FName SessionName, EOnJoinSessionCompleteResult::Type Result);
void HandleDestroySessionComplete(FName SessionName, bool bWasSuccessful);
/**
* L'ami a clique "Rejoindre la partie" dans l'overlay Steam.
*
* Ecoute des l'Initialize et jamais coupee : une invitation peut tomber a
* n'importe quel instant, y compris en pleine partie ou alors qu'aucun menu
* n'est affiche. Le sous-systeme est le seul objet dont on soit certain
* qu'il soit vivant a ce moment-la.
*/
void HandleInviteAccepted(const bool bWasSuccessful, const int32 ControllerId, FUniqueNetIdPtr UserId, const FOnlineSessionSearchResult& InviteResult);
/**
* Second temps de la commande EmberJoin : la recherche a rendu, on rejoint.
* UFUNCTION obligatoire, c'est ce qui permet le AddDynamic sur OnSearchFinished.
*/
UFUNCTION()
void HandleExecJoinSearchFinished(bool bWasSuccessful, int32 SessionCount);
/** La creation proprement dite, une fois le terrain degage. */
void CreateSessionNow();
void JoinSearchResult(const FOnlineSessionSearchResult& Result);
/**
* Vrai quand le service charge est OnlineSubsystemNULL, c'est-a-dire qu'on
* n'a pas Steam. Toutes les sessions basculent alors en LAN : c'est ce qui
* rend le test a deux instances possible sur une seule machine.
*/
bool IsLanMode() const;
/** Coupe proprement un delegue encore branche. Idempotent. */
void ClearPendingDelegates();
IOnlineSessionPtr SessionInterface;
TSharedPtr<FOnlineSessionSearch> SearchSettings;
FDelegateHandle CreateSessionCompleteHandle;
FDelegateHandle FindSessionsCompleteHandle;
FDelegateHandle JoinSessionCompleteHandle;
FDelegateHandle DestroySessionCompleteHandle;
FDelegateHandle InviteAcceptedHandle;
/** Map a ouvrir une fois la session creee. */
TSoftObjectPtr<UWorld> PendingHostLevel;
int32 PendingMaxPlayers = DefaultMaxPlayers;
/**
* Une session trainait encore : on la detruit d'abord et la creation reprend
* dans le callback de destruction. Sans ce drapeau, quitter une partie puis
* en relancer une echoue silencieusement -- le moteur refuse deux sessions
* du meme nom, et l'erreur ne remonte nulle part.
*/
bool bCreateAfterDestroy = false;
};
@@ -64,6 +64,19 @@ public:
virtual void Tick(float DeltaTime) override;
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
/** Le couvercle vient de changer d'etat cote serveur : on joue l'animation. */
UFUNCTION()
void OnRep_Open();
/**
* Son et relance du tick d'animation. Partage par SetOpen (serveur) et
* OnRep_Open (clients) : deux copies finiraient par diverger, et le
* symptome serait un couvercle muet chez les invites.
*/
void ApplyOpenState();
// --- IInteractable ---
virtual FText GetInteractionPrompt_Implementation(AActor* Interactor) const override;
virtual void Interact_Implementation(AActor* Interactor) override;
@@ -87,6 +100,11 @@ public:
* parce qu'il s'est trop eloigne. Un coffre qui deciderait seul finirait
* ouvert dans la moitie de ces cas.
*/
/**
* Leve ou referme le couvercle. **Serveur uniquement** : le couvercle est
* un etat du MONDE, les quatre joueurs doivent le voir bouger. Un client
* qui l'appellerait verrait son coffre s'ouvrir tout seul chez lui.
*/
UFUNCTION(BlueprintCallable, Category = "Storage")
void SetOpen(bool bInOpen);
@@ -232,6 +250,11 @@ private:
/** Cale la boite sur le volume combine des deux meshes, couvercle ferme. */
void FitInteractionBox();
/**
* Repliqué à tout le monde : voir le couvercle d'un coffre se lever est le
* seul signe qu'un coéquipier est en train de fouiller dedans.
*/
UPROPERTY(ReplicatedUsing = OnRep_Open)
bool bOpen = false;
/** 0 = ferme, 1 = ouvert. Interpole, jamais pose directement. */
@@ -0,0 +1,51 @@
// Fill out your copyright notice in the Description page of Project Settings.
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/GameModeBase.h"
#include "SurvivalGameMode.generated.h"
/**
* L'arbitre de la partie. N'existe QUE sur le serveur -- sur un client,
* GetGameMode() rend nullptr, toujours. Ce n'est pas un bug mais le design du
* framework : le GameMode decide, le GameState montre.
*
* Corollaire a garder en tete pour tout le portage coop : le moindre appel a
* GetGameMode() ecrit du cote client doit etre remplace par un RPC serveur ou
* par une lecture du GameState. C'est le cas du respawn, aujourd'hui declenche
* depuis AFpsPlayerController.
*
* Y mettra plus tard : les regles de mort, le cycle jour/nuit, la sauvegarde
* du monde. Pour l'instant il ne fait que borner le nombre de joueurs et
* cabler GameState et PlayerState.
*/
UCLASS()
class SURVIVAL_PROJET_API ASurvivalGameMode : public AGameModeBase
{
GENERATED_BODY()
public:
ASurvivalGameMode();
virtual void InitGame(const FString& MapName, const FString& Options, FString& ErrorMessage) override;
virtual void PostLogin(APlayerController* NewPlayer) override;
virtual void Logout(AController* Exiting) override;
protected:
/**
* Plafond de joueurs, applique a la AGameSession.
*
* Regle ici et pas seulement dans les reglages de session : la session
* annonce des places libres, mais c'est le GameMode qui refuse reellement la
* connexion. Sans ce plafond, un cinquieme joueur qui arrive par une
* invitation directe entre quand meme.
*
* Sa valeur par defaut est posee dans le constructeur depuis
* USessionSubsystem::DefaultMaxPlayers, pour qu'il n'existe qu'UN seul 4
* dans le projet : deux nombres qui doivent rester egaux finissent toujours
* par diverger, et le symptome serait une place annoncee mais refusee.
*/
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Coop", meta = (ClampMin = "1", ClampMax = "16"))
int32 MaxCoopPlayers;
};
@@ -34,6 +34,8 @@ public:
virtual void BeginPlay() override;
virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override;
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
/** Rend de la vie, de la faim et de la soif. Les valeurs sont bornees au max. */
UFUNCTION(BlueprintCallable, Category = "Survie")
void ApplyRestore(float HealthAmount, float HungerAmount, float ThirstAmount);
@@ -106,11 +108,38 @@ private:
/** Appelee par le timer : fait descendre les jauges et applique les degats. */
void UpdateStats();
/**
* Les jauges viennent d'arriver du serveur : on rediffuse le delegue
* habituel, et WBP_SurvivalStats se met a jour sans une ligne de changement.
*/
UFUNCTION()
void OnRep_Stats();
FTimerHandle UpdateTimerHandle;
// La simulation tourne UNIQUEMENT sur le serveur : quatre clients qui
// feraient descendre leur faim chacun de leur cote divergeraient en
// quelques minutes, et le premier a mourir ne serait pas le meme partout.
// Les clients ne font que recevoir.
UPROPERTY(ReplicatedUsing = OnRep_Stats)
float Health = 0.f;
UPROPERTY(ReplicatedUsing = OnRep_Stats)
float Hunger = 0.f;
UPROPERTY(ReplicatedUsing = OnRep_Stats)
float Thirst = 0.f;
/**
* ReplicatedUsing et non simplement Replicated : OnDied n'est diffuse que
* sur le serveur, or c'est ce delegue qui declenche l'ecran de mort et le
* respawn cote joueur. Sans OnRep, un client mourrait sans jamais le savoir
* et continuerait a jouer un personnage que le serveur considere mort.
*/
UPROPERTY(ReplicatedUsing = OnRep_Dead)
bool bDead = false;
UFUNCTION()
void OnRep_Dead();
};
@@ -19,9 +19,15 @@ public class Survival_projet : ModuleRules
PrivateDependencyModuleNames.AddRange(new string[] { "Slate", "SlateCore" });
// Uncomment if you are using online features
// PrivateDependencyModuleNames.Add("OnlineSubsystem");
// To include OnlineSubsystemSteam, add it to the plugins section in your uproject file with the Enabled attribute set to true
// Coop Steam : on ne depend QUE de l'API generique (OnlineSubsystem) et de
// ses helpers (OnlineSubsystemUtils, qui porte le resolveur d'adresses).
// Surtout PAS de OnlineSubsystemSteam en dur -- le code appellerait alors
// du Steam explicitement et ne compilerait plus sur une plateforme sans
// Steam. Le choix du service se fait par ini.
//
// Public et non Private : SessionSubsystem.h expose IOnlineSessionPtr et
// FOnlineSessionSearchResult dans sa declaration. Un header public qui
// montre les types d'un module prive ne compile pas chez celui qui l'inclut.
PublicDependencyModuleNames.AddRange(new string[] { "OnlineSubsystem", "OnlineSubsystemUtils" });
}
}
+8
View File
@@ -31,6 +31,14 @@
"TargetAllowList": [
"Editor"
]
},
{
"Name": "OnlineSubsystemSteam",
"Enabled": true
},
{
"Name": "SteamSockets",
"Enabled": true
}
]
}