(Feat) Add mulitplayer
This commit is contained in:
+171
-3
@@ -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
|
**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_`,
|
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.
|
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
|
**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
|
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` |
|
| Menu de pause | `PauseMenuWidget` + la partie pause de `FpsPlayerController` |
|
||||||
| Réglages | `SurvivalUserSettings`, `SettingsMenuWidget`, `SettingsTabWidget`, `SettingsGameplayWidget`, `SettingRowWidget` |
|
| Réglages | `SurvivalUserSettings`, `SettingsMenuWidget`, `SettingsTabWidget`, `SettingsGameplayWidget`, `SettingRowWidget` |
|
||||||
| Fondus, boîtes de dialogue | `ScreenFadeComponent`, `ConfirmDialogWidget` |
|
| 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
|
### Décisions de gameplay figées
|
||||||
|
|
||||||
@@ -148,9 +311,14 @@ qu'une compilation propre l'est vraiment.
|
|||||||
**relit** à son `BeginPlay` et s'abonne à `OnSurvivalSettingsApplied`.
|
**relit** à son `BeginPlay` et s'abonne à `OnSurvivalSettingsApplied`.
|
||||||
- **Échap** a une priorité explicite dans `HandlePauseInput()` : boîte de confirmation →
|
- **É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.
|
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
|
- **Menu de pause : il ne met plus rien en pause.** `SetPause()` a disparu du projet. En coop la
|
||||||
`ScreenFadeComponent` continuent (d'où son `bTickEvenWhenPaused`). Pas de bouton « Quitter le
|
pause est impossible par nature — côté client l'appel est ignoré (elle appartient à l'autorité),
|
||||||
jeu » : la sortie se fait depuis le menu principal.
|
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
|
- **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.
|
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
|
L'écran ignore qui l'ouvre (`OnCloseRequested`), d'où un seul `WBP_Settings` pour le menu
|
||||||
|
|||||||
@@ -92,6 +92,46 @@ FontDPI=72
|
|||||||
; n'existent tout simplement pas au runtime.
|
; n'existent tout simplement pas au runtime.
|
||||||
GameUserSettingsClassName=/Script/Survival_projet.SurvivalUserSettings
|
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]
|
[/Script/AndroidFileServerEditor.AndroidFileServerRuntimeSettings]
|
||||||
bEnablePlugin=True
|
bEnablePlugin=True
|
||||||
bAllowNetworkConnection=True
|
bAllowNetworkConnection=True
|
||||||
|
|||||||
@@ -13,3 +13,15 @@ ProjectID=4774BE68499C24D0A70C6E87B49DA419
|
|||||||
[/Script/DiscordRichPresence.DiscordRichPresenceSettings]
|
[/Script/DiscordRichPresence.DiscordRichPresenceSettings]
|
||||||
ClientId=1432045921620725933
|
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 "ItemDataAsset.h"
|
||||||
#include "Kismet/GameplayStatics.h"
|
#include "Kismet/GameplayStatics.h"
|
||||||
#include "NiagaraComponent.h"
|
#include "NiagaraComponent.h"
|
||||||
|
#include "Net/UnrealNetwork.h"
|
||||||
#include "TimerManager.h"
|
#include "TimerManager.h"
|
||||||
|
|
||||||
#define LOCTEXT_NAMESPACE "CookingStation"
|
#define LOCTEXT_NAMESPACE "CookingStation"
|
||||||
@@ -20,6 +21,14 @@ ACookingStation::ACookingStation()
|
|||||||
// Tout passe par des timers : la station n'a rien a faire chaque frame.
|
// Tout passe par des timers : la station n'a rien a faire chaque frame.
|
||||||
PrimaryActorTick.bCanEverTick = false;
|
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"));
|
BaseMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("BaseMesh"));
|
||||||
SetRootComponent(BaseMesh);
|
SetRootComponent(BaseMesh);
|
||||||
// Le foyer est un obstacle physique, contrairement a un ramassable : on
|
// 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()
|
void ACookingStation::BeginPlay()
|
||||||
{
|
{
|
||||||
Super::BeginPlay();
|
Super::BeginPlay();
|
||||||
|
|
||||||
Slots.SetNum(MaxCookingSlots);
|
// Recensement des buches AVANT tout : il fixe la capacite, et il est purement
|
||||||
|
// local -- ce sont des composants du Blueprint, identiques partout.
|
||||||
// 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.
|
|
||||||
CacheFuelIndicators();
|
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)
|
if (StartingFuelItem && StartingFuelItem->bIsFuel)
|
||||||
{
|
{
|
||||||
const int32 Count = FMath::Min(StartingFuelCount, GetFuelCapacity());
|
const int32 Count = FMath::Min(StartingFuelCount, GetFuelCapacity());
|
||||||
@@ -103,6 +141,16 @@ void ACookingStation::BeginPlay()
|
|||||||
NewFuel.RemainingSeconds = FMath::Max(1.f, StartingFuelItem->FuelDuration);
|
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();
|
RefreshFuelIndicators();
|
||||||
|
|
||||||
@@ -110,7 +158,11 @@ void ACookingStation::BeginPlay()
|
|||||||
// arrive. On passe quand meme par le point de decision unique plutot que
|
// arrive. On passe quand meme par le point de decision unique plutot que
|
||||||
// d'ecrire un cas particulier de plus.
|
// d'ecrire un cas particulier de plus.
|
||||||
ApplyFireVisuals(false);
|
ApplyFireVisuals(false);
|
||||||
|
|
||||||
|
if (HasAuthority())
|
||||||
|
{
|
||||||
UpdateFireState();
|
UpdateFireState();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void ACookingStation::EndPlay(const EEndPlayReason::Type EndPlayReason)
|
void ACookingStation::EndPlay(const EEndPlayReason::Type EndPlayReason)
|
||||||
@@ -245,6 +297,14 @@ bool ACookingStation::HasPendingWork() const
|
|||||||
|
|
||||||
void ACookingStation::UpdateFireState()
|
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
|
// 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
|
// 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.
|
// 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.
|
// Necessaire pour que InitializeComponent() soit appele.
|
||||||
bWantsInitializeComponent = true;
|
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()
|
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)
|
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;
|
ECraftFailureReason Reason = ECraftFailureReason::None;
|
||||||
if (!CanCraft(Recipe, Reason))
|
if (!CanCraft(Recipe, Reason))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
#include "InteractionComponent.h"
|
#include "InteractionComponent.h"
|
||||||
#include "InventoryComponent.h"
|
#include "InventoryComponent.h"
|
||||||
#include "ItemDataAsset.h"
|
#include "ItemDataAsset.h"
|
||||||
|
#include "Net/UnrealNetwork.h"
|
||||||
#include "PickupItem.h"
|
#include "PickupItem.h"
|
||||||
#include "SurvivalStatsComponent.h"
|
#include "SurvivalStatsComponent.h"
|
||||||
#include "SurvivalUserSettings.h"
|
#include "SurvivalUserSettings.h"
|
||||||
@@ -448,14 +449,58 @@ void AFpsPlayer::StartSprint()
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
bIsSprinting = true;
|
SetSprinting(true);
|
||||||
GetCharacterMovement()->MaxWalkSpeed = SprintSpeed;
|
}
|
||||||
|
|
||||||
|
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()
|
void AFpsPlayer::StopSprint()
|
||||||
{
|
{
|
||||||
bIsSprinting = false;
|
SetSprinting(false);
|
||||||
GetCharacterMovement()->MaxWalkSpeed = WalkSpeed;
|
}
|
||||||
|
|
||||||
|
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()
|
void AFpsPlayer::HandleSprintReleased()
|
||||||
@@ -471,7 +516,33 @@ void AFpsPlayer::HandleSprintReleased()
|
|||||||
|
|
||||||
void AFpsPlayer::UseSelectedItem()
|
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())
|
if (Selected.IsEmpty())
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -493,7 +564,7 @@ void AFpsPlayer::UseSelectedItem()
|
|||||||
// usages multiples (une charge en moins, l'objet reste tant qu'il en a).
|
// 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
|
// On vise le slot actif precisement, et pas RemoveItem qui piocherait
|
||||||
// n'importe quelle pile du meme objet, y compris dans le sac.
|
// 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
|
FVector AFpsPlayer::PlaceOnGround(const FVector& Start) const
|
||||||
@@ -543,6 +614,11 @@ bool AFpsPlayer::DropSlot(int32 SlotIndex, int32 Quantity)
|
|||||||
return DropFromInventory(InventoryComponent, SlotIndex, 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)
|
bool AFpsPlayer::DropFromInventory(UInventoryComponent* Inventory, int32 SlotIndex, int32 Quantity)
|
||||||
{
|
{
|
||||||
if (!Inventory)
|
if (!Inventory)
|
||||||
@@ -550,6 +626,16 @@ bool AFpsPlayer::DropFromInventory(UInventoryComponent* Inventory, int32 SlotInd
|
|||||||
return false;
|
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)
|
if (!DroppedPickupClass)
|
||||||
{
|
{
|
||||||
UE_LOG(LogTemp, Warning, TEXT("AFpsPlayer : DroppedPickupClass n'est pas assignee, impossible de jeter un objet."));
|
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()
|
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)
|
if (!DroppedPickupClass)
|
||||||
{
|
{
|
||||||
UE_LOG(LogTemp, Warning, TEXT("AFpsPlayer : DroppedPickupClass n'est pas assignee, le butin est perdu."));
|
UE_LOG(LogTemp, Warning, TEXT("AFpsPlayer : DroppedPickupClass n'est pas assignee, le butin est perdu."));
|
||||||
|
|||||||
@@ -16,9 +16,14 @@
|
|||||||
#include "InteractionPromptWidget.h"
|
#include "InteractionPromptWidget.h"
|
||||||
#include "CraftingComponent.h"
|
#include "CraftingComponent.h"
|
||||||
#include "InventoryScreenWidget.h"
|
#include "InventoryScreenWidget.h"
|
||||||
|
#include "Engine/LocalPlayer.h"
|
||||||
|
#include "GameFramework/PlayerState.h"
|
||||||
|
#include "Interfaces/OnlineIdentityInterface.h"
|
||||||
#include "Kismet/GameplayStatics.h"
|
#include "Kismet/GameplayStatics.h"
|
||||||
|
#include "OnlineSubsystem.h"
|
||||||
#include "PauseMenuWidget.h"
|
#include "PauseMenuWidget.h"
|
||||||
#include "ScreenFadeComponent.h"
|
#include "ScreenFadeComponent.h"
|
||||||
|
#include "SessionSubsystem.h"
|
||||||
#include "SettingsMenuWidget.h"
|
#include "SettingsMenuWidget.h"
|
||||||
#include "StorageContainer.h"
|
#include "StorageContainer.h"
|
||||||
#include "SurvivalStatsWidget.h"
|
#include "SurvivalStatsWidget.h"
|
||||||
@@ -87,6 +92,23 @@ void AFpsPlayerController::BeginPlay()
|
|||||||
{
|
{
|
||||||
SetInputMode(FInputModeGameOnly());
|
SetInputMode(FInputModeGameOnly());
|
||||||
SetShowMouseCursor(false);
|
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
|
// Le delegue se declenche a la fin de CHAQUE fondu : c'est
|
||||||
@@ -143,6 +165,21 @@ void AFpsPlayerController::HandlePawnDied()
|
|||||||
}
|
}
|
||||||
|
|
||||||
void AFpsPlayerController::RespawnPlayer()
|
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
|
// On depossede AVANT de detruire : detruire un pawn encore possede laisse
|
||||||
// le controller dans un etat incoherent le temps d'une frame.
|
// 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."));
|
UE_LOG(LogTemp, Error, TEXT("AFpsPlayerController : aucun GameMode, impossible de reapparaitre."));
|
||||||
}
|
}
|
||||||
|
|
||||||
ScreenFade->FadeIn(StartupFadeDuration);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void AFpsPlayerController::SetupInputComponent()
|
void AFpsPlayerController::SetupInputComponent()
|
||||||
@@ -456,6 +491,32 @@ void AFpsPlayerController::OpenCraftingAtStation(ECraftingStation Station)
|
|||||||
return;
|
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
|
// Deja ouvert sur un autre poste : on rend le precedent avant d'accorder le
|
||||||
// nouveau, sinon le compteur du composant ne redescendrait jamais.
|
// nouveau, sinon le compteur du composant ne redescendrait jamais.
|
||||||
ReleaseActiveStation();
|
ReleaseActiveStation();
|
||||||
@@ -465,8 +526,22 @@ void AFpsPlayerController::OpenCraftingAtStation(ECraftingStation Station)
|
|||||||
Crafting->AddAvailableStation(Station);
|
Crafting->AddAvailableStation(Station);
|
||||||
ActiveStation = 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()
|
void AFpsPlayerController::ReleaseActiveStation()
|
||||||
@@ -481,6 +556,14 @@ void AFpsPlayerController::ReleaseActiveStation()
|
|||||||
Crafting->RemoveAvailableStation(ActiveStation);
|
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;
|
ActiveStation = ECraftingStation::Count;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -600,7 +683,22 @@ void AFpsPlayerController::SetUiInputMode(bool bUiOpen)
|
|||||||
|
|
||||||
void AFpsPlayerController::OpenStorage(AStorageContainer* Container)
|
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;
|
return;
|
||||||
}
|
}
|
||||||
@@ -613,6 +711,10 @@ void AFpsPlayerController::OpenStorage(AStorageContainer* Container)
|
|||||||
bStorageOpen = true;
|
bStorageOpen = true;
|
||||||
|
|
||||||
InventoryScreen->ShowStorage(Container);
|
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);
|
Container->SetOpen(true);
|
||||||
|
|
||||||
// La barre rapide reste visible et cliquable par-dessus l'ecran : on lui
|
// 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);
|
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()
|
void AFpsPlayerController::CloseStorage()
|
||||||
{
|
{
|
||||||
if (!bStorageOpen)
|
if (!bStorageOpen)
|
||||||
@@ -658,9 +768,19 @@ void AFpsPlayerController::ReleaseActiveStorage()
|
|||||||
GetWorldTimerManager().ClearTimer(StorageDistanceTimerHandle);
|
GetWorldTimerManager().ClearTimer(StorageDistanceTimerHandle);
|
||||||
|
|
||||||
if (AStorageContainer* Container = ActiveStorage.Get())
|
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);
|
Container->SetOpen(false);
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Server_ReleaseStorage(Container);
|
||||||
|
}
|
||||||
|
}
|
||||||
ActiveStorage.Reset();
|
ActiveStorage.Reset();
|
||||||
|
|
||||||
if (InventoryScreen)
|
if (InventoryScreen)
|
||||||
@@ -760,18 +880,18 @@ void AFpsPlayerController::OpenPauseMenu()
|
|||||||
bPauseMenuOpen = true;
|
bPauseMenuOpen = true;
|
||||||
PauseMenu->SetVisibility(ESlateVisibility::Visible);
|
PauseMenu->SetVisibility(ESlateVisibility::Visible);
|
||||||
|
|
||||||
// GameAndUI et non UIOnly, meme raison que l'inventaire : en UIOnly plus
|
// AUCUN SetPause : en coop la pause est impossible par nature. Cote client
|
||||||
// aucune action Enhanced Input ne passe, et Echap ne refermerait rien.
|
// l'appel est ignore -- la pause appartient a l'autorite -- et cote hote il
|
||||||
FInputModeGameAndUI Mode;
|
// figerait le monde des quatre joueurs parce qu'un seul a ouvert son menu.
|
||||||
Mode.SetLockMouseToViewportBehavior(EMouseLockMode::DoNotLock);
|
// Le monde continue donc de tourner, comme dans Valheim ou Raft : ouvrir son
|
||||||
Mode.SetHideCursorDuringCapture(false);
|
// menu ne met pas a l'abri, et c'est coherent avec un jeu de survie.
|
||||||
SetInputMode(Mode);
|
//
|
||||||
SetShowMouseCursor(true);
|
// Consequence directe : il faut desormais couper l'input du PAWN, ce que la
|
||||||
|
// pause faisait gratuitement. C'est exactement le travail de SetUiInputMode,
|
||||||
// Pas de DisableInput sur le pawn ici, contrairement a l'inventaire : le
|
// deja utilise par l'inventaire et le coffre -- un second chemin finirait
|
||||||
// monde est reellement fige, et les actions du pawn n'ont pas
|
// par diverger, et le symptome (camera qui tourne derriere le menu, sprint
|
||||||
// "Trigger When Paused" -- elles ne se declenchent donc plus d'elles-memes.
|
// bloque en sortant) se cherche tres longtemps.
|
||||||
SetPause(true);
|
SetUiInputMode(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
void AFpsPlayerController::ResumeGame()
|
void AFpsPlayerController::ResumeGame()
|
||||||
@@ -800,10 +920,65 @@ void AFpsPlayerController::ResumeGame()
|
|||||||
PauseMenu->SetVisibility(ESlateVisibility::Collapsed);
|
PauseMenu->SetVisibility(ESlateVisibility::Collapsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
SetPause(false);
|
SetUiInputMode(false);
|
||||||
|
}
|
||||||
|
|
||||||
SetInputMode(FInputModeGameOnly());
|
FString AFpsPlayerController::ResolveLocalPlayerNickname() const
|
||||||
SetShowMouseCursor(false);
|
{
|
||||||
|
// 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()
|
void AFpsPlayerController::OpenSettings()
|
||||||
@@ -957,11 +1132,6 @@ void AFpsPlayerController::HandleFadeFinished()
|
|||||||
return;
|
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);
|
UGameplayStatics::OpenLevelBySoftObjectPtr(this, MainMenuLevel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,12 +12,26 @@
|
|||||||
UInteractionComponent::UInteractionComponent()
|
UInteractionComponent::UInteractionComponent()
|
||||||
{
|
{
|
||||||
PrimaryComponentTick.bCanEverTick = true;
|
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)
|
void UInteractionComponent::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction)
|
||||||
{
|
{
|
||||||
Super::TickComponent(DeltaTime, TickType, 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
|
// 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.
|
// l'affichage du prompt n'a pas besoin d'etre rafraichi chaque frame.
|
||||||
TimeSinceLastTrace += DeltaTime;
|
TimeSinceLastTrace += DeltaTime;
|
||||||
@@ -158,14 +172,55 @@ void UInteractionComponent::TryInteract()
|
|||||||
return;
|
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
|
// L'interaction vient peut-etre de detruire la cible, ou de la rendre
|
||||||
// non-interactive (porte maintenant ouverte). On reevalue tout de suite
|
// non-interactive (porte maintenant ouverte). On reevalue tout de suite
|
||||||
// plutot que d'attendre le prochain trace periodique.
|
// 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();
|
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()
|
void UInteractionComponent::RefreshPrompt()
|
||||||
{
|
{
|
||||||
AActor* Target = FocusedActor.Get();
|
AActor* Target = FocusedActor.Get();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
#include "InventoryComponent.h"
|
#include "InventoryComponent.h"
|
||||||
|
|
||||||
#include "ItemDataAsset.h"
|
#include "ItemDataAsset.h"
|
||||||
|
#include "Net/UnrealNetwork.h"
|
||||||
|
|
||||||
UInventoryComponent::UInventoryComponent()
|
UInventoryComponent::UInventoryComponent()
|
||||||
{
|
{
|
||||||
@@ -11,6 +12,9 @@ UInventoryComponent::UInventoryComponent()
|
|||||||
|
|
||||||
// Necessaire pour que InitializeComponent() soit appele.
|
// Necessaire pour que InitializeComponent() soit appele.
|
||||||
bWantsInitializeComponent = true;
|
bWantsInitializeComponent = true;
|
||||||
|
|
||||||
|
// Le contenu appartient au serveur et descend vers les clients.
|
||||||
|
SetIsReplicatedByDefault(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
void UInventoryComponent::InitializeComponent()
|
void UInventoryComponent::InitializeComponent()
|
||||||
@@ -22,6 +26,30 @@ void UInventoryComponent::InitializeComponent()
|
|||||||
SelectedHotbarIndex = FMath::Clamp(SelectedHotbarIndex, 0, FMath::Max(0, GetHotbarSlotCount() - 1));
|
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)
|
void UInventoryComponent::SelectHotbarSlot(int32 Index)
|
||||||
{
|
{
|
||||||
const int32 Count = GetHotbarSlotCount();
|
const int32 Count = GetHotbarSlotCount();
|
||||||
@@ -178,8 +206,27 @@ int32 UInventoryComponent::RemoveItem(UItemDataAsset* Item, int32 Quantity)
|
|||||||
return Removed;
|
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)
|
bool UInventoryComponent::ConsumeUse(int32 SlotIndex)
|
||||||
{
|
{
|
||||||
|
if (!HasInventoryAuthority())
|
||||||
|
{
|
||||||
|
Server_ConsumeUse(SlotIndex);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (!Slots.IsValidIndex(SlotIndex))
|
if (!Slots.IsValidIndex(SlotIndex))
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
@@ -419,6 +466,28 @@ bool UInventoryComponent::ApplySlotMove(FInventorySlot& From, FInventorySlot& To
|
|||||||
return true;
|
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)
|
bool UInventoryComponent::TransferSlot(UInventoryComponent* From, int32 FromIndex, UInventoryComponent* To, int32 ToIndex, int32 Quantity)
|
||||||
{
|
{
|
||||||
if (!From || !To || Quantity <= 0)
|
if (!From || !To || Quantity <= 0)
|
||||||
@@ -426,6 +495,43 @@ bool UInventoryComponent::TransferSlot(UInventoryComponent* From, int32 FromInde
|
|||||||
return false;
|
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
|
// 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
|
// couple et non sur l'index seul, deux inventaires differents ayant tous
|
||||||
// les deux une case 3.
|
// les deux une case 3.
|
||||||
@@ -491,6 +597,21 @@ int32 UInventoryComponent::QuickTransferSlot(UInventoryComponent* From, int32 Fr
|
|||||||
return 0;
|
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);
|
const int32 Moved = From->PushSlotInto(FromIndex, *To);
|
||||||
if (Moved > 0)
|
if (Moved > 0)
|
||||||
{
|
{
|
||||||
@@ -500,6 +621,11 @@ int32 UInventoryComponent::QuickTransferSlot(UInventoryComponent* From, int32 Fr
|
|||||||
return Moved;
|
return Moved;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void UInventoryComponent::Server_QuickTransferSlot_Implementation(UInventoryComponent* From, int32 FromIndex, UInventoryComponent* To)
|
||||||
|
{
|
||||||
|
QuickTransferSlot(From, FromIndex, To);
|
||||||
|
}
|
||||||
|
|
||||||
int32 UInventoryComponent::TransferAllTo(UInventoryComponent* To, int32 FirstIndex)
|
int32 UInventoryComponent::TransferAllTo(UInventoryComponent* To, int32 FirstIndex)
|
||||||
{
|
{
|
||||||
if (!To || To == this)
|
if (!To || To == this)
|
||||||
@@ -507,6 +633,17 @@ int32 UInventoryComponent::TransferAllTo(UInventoryComponent* To, int32 FirstInd
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!HasInventoryAuthority())
|
||||||
|
{
|
||||||
|
if (UInventoryComponent* Proxy = FindNetProxy(this, To))
|
||||||
|
{
|
||||||
|
Proxy->Server_TransferAllTo(this, To, FirstIndex);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
To->BeginBatch();
|
To->BeginBatch();
|
||||||
|
|
||||||
int32 Moved = 0;
|
int32 Moved = 0;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
#include "MainMenuWidget.h"
|
#include "MainMenuWidget.h"
|
||||||
#include "MenuCameraSpot.h"
|
#include "MenuCameraSpot.h"
|
||||||
#include "ScreenFadeComponent.h"
|
#include "ScreenFadeComponent.h"
|
||||||
|
#include "SessionSubsystem.h"
|
||||||
#include "SettingsMenuWidget.h"
|
#include "SettingsMenuWidget.h"
|
||||||
#include "SurvivalGameInstance.h"
|
#include "SurvivalGameInstance.h"
|
||||||
|
|
||||||
@@ -78,6 +79,18 @@ void AMainMenuPlayerController::BeginPlay()
|
|||||||
|
|
||||||
ScreenFade->OnFadeFinished.AddDynamic(this, &AMainMenuPlayerController::HandleFadeFinished);
|
ScreenFade->OnFadeFinished.AddDynamic(this, &AMainMenuPlayerController::HandleFadeFinished);
|
||||||
ScreenFade->FadeIn(OpeningFadeDuration);
|
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()
|
AMenuCameraSpot* AMainMenuPlayerController::PickCameraSpot()
|
||||||
@@ -135,7 +148,7 @@ AMenuCameraSpot* AMainMenuPlayerController::PickCameraSpot()
|
|||||||
return Chosen;
|
return Chosen;
|
||||||
}
|
}
|
||||||
|
|
||||||
void AMainMenuPlayerController::StartSoloGame()
|
void AMainMenuPlayerController::StartGame()
|
||||||
{
|
{
|
||||||
// Le fondu est deja lance : on ignore le second clic plutot que d'empiler
|
// Le fondu est deja lance : on ignore le second clic plutot que d'empiler
|
||||||
// deux chargements.
|
// deux chargements.
|
||||||
@@ -162,6 +175,33 @@ void AMainMenuPlayerController::StartSoloGame()
|
|||||||
ScreenFade->FadeOut(LeaveFadeDuration);
|
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()
|
void AMainMenuPlayerController::QuitGame()
|
||||||
{
|
{
|
||||||
// Une partie est deja en cours de chargement : la question n'a plus de sens.
|
// 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
|
// 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.
|
// 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);
|
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()
|
void UMainMenuWidget::HandlePlayClicked()
|
||||||
{
|
{
|
||||||
if (AMainMenuPlayerController* Controller = GetOwningPlayer<AMainMenuPlayerController>())
|
if (AMainMenuPlayerController* Controller = GetOwningPlayer<AMainMenuPlayerController>())
|
||||||
{
|
{
|
||||||
Controller->StartSoloGame();
|
Controller->StartGame();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,17 @@ void UPauseMenuWidget::NativeConstruct()
|
|||||||
{
|
{
|
||||||
SettingsButton->OnClicked.AddDynamic(this, &UPauseMenuWidget::HandleSettingsClicked);
|
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()
|
void UPauseMenuWidget::NativeDestruct()
|
||||||
@@ -43,6 +54,11 @@ void UPauseMenuWidget::NativeDestruct()
|
|||||||
QuitToMenuButton->OnClicked.RemoveDynamic(this, &UPauseMenuWidget::HandleQuitToMenuClicked);
|
QuitToMenuButton->OnClicked.RemoveDynamic(this, &UPauseMenuWidget::HandleQuitToMenuClicked);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (InviteButton)
|
||||||
|
{
|
||||||
|
InviteButton->OnClicked.RemoveDynamic(this, &UPauseMenuWidget::HandleInviteClicked);
|
||||||
|
}
|
||||||
|
|
||||||
Super::NativeDestruct();
|
Super::NativeDestruct();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,3 +85,11 @@ void UPauseMenuWidget::HandleQuitToMenuClicked()
|
|||||||
Controller->RequestQuitToMainMenu();
|
Controller->RequestQuitToMainMenu();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void UPauseMenuWidget::HandleInviteClicked()
|
||||||
|
{
|
||||||
|
if (AFpsPlayerController* Controller = GetOwningPlayer<AFpsPlayerController>())
|
||||||
|
{
|
||||||
|
Controller->ShowInviteFriends();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
#include "Components/StaticMeshComponent.h"
|
#include "Components/StaticMeshComponent.h"
|
||||||
#include "InventoryComponent.h"
|
#include "InventoryComponent.h"
|
||||||
#include "ItemDataAsset.h"
|
#include "ItemDataAsset.h"
|
||||||
|
#include "Net/UnrealNetwork.h"
|
||||||
|
|
||||||
#define LOCTEXT_NAMESPACE "PickupItem"
|
#define LOCTEXT_NAMESPACE "PickupItem"
|
||||||
|
|
||||||
@@ -14,6 +15,15 @@ APickupItem::APickupItem()
|
|||||||
{
|
{
|
||||||
PrimaryActorTick.bCanEverTick = false;
|
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"));
|
Mesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("Mesh"));
|
||||||
SetRootComponent(Mesh);
|
SetRootComponent(Mesh);
|
||||||
// Purement decoratif : c'est la sphere qui porte la collision.
|
// Purement decoratif : c'est la sphere qui porte la collision.
|
||||||
@@ -29,6 +39,15 @@ APickupItem::APickupItem()
|
|||||||
InteractionSphere->SetCollisionResponseToChannel(ECC_Visibility, ECR_Block);
|
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)
|
void APickupItem::InitPickup(UItemDataAsset* InItemData, int32 InQuantity, int32 InRemainingUses)
|
||||||
{
|
{
|
||||||
ItemData = InItemData;
|
ItemData = InItemData;
|
||||||
@@ -36,6 +55,19 @@ void APickupItem::InitPickup(UItemDataAsset* InItemData, int32 InQuantity, int32
|
|||||||
RemainingUses = InRemainingUses;
|
RemainingUses = InRemainingUses;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void APickupItem::ApplyItemVisuals()
|
||||||
|
{
|
||||||
|
if (bUseMeshFromItemData && ItemData && ItemData->WorldMesh)
|
||||||
|
{
|
||||||
|
ItemData->ApplyWorldVisualsTo(Mesh);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void APickupItem::OnRep_ItemData()
|
||||||
|
{
|
||||||
|
ApplyItemVisuals();
|
||||||
|
}
|
||||||
|
|
||||||
void APickupItem::OnConstruction(const FTransform& Transform)
|
void APickupItem::OnConstruction(const FTransform& Transform)
|
||||||
{
|
{
|
||||||
Super::OnConstruction(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
|
// 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
|
// 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.
|
// pour les objets jetes, puisque OnConstruction s'execute a FinishSpawning.
|
||||||
if (bUseMeshFromItemData && ItemData && ItemData->WorldMesh)
|
//
|
||||||
{
|
// Chez un client, ItemData est encore nul a cet instant : c'est OnRep_ItemData
|
||||||
ItemData->ApplyWorldVisualsTo(Mesh);
|
// qui reposera le visuel quand la propriete arrivera.
|
||||||
}
|
ApplyItemVisuals();
|
||||||
}
|
}
|
||||||
|
|
||||||
FText APickupItem::GetInteractionPrompt_Implementation(AActor* Interactor) const
|
FText APickupItem::GetInteractionPrompt_Implementation(AActor* Interactor) const
|
||||||
@@ -75,6 +107,16 @@ bool APickupItem::CanInteract_Implementation(AActor* Interactor) const
|
|||||||
|
|
||||||
void APickupItem::Interact_Implementation(AActor* Interactor)
|
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;
|
UInventoryComponent* Inventory = Interactor ? Interactor->FindComponentByClass<UInventoryComponent>() : nullptr;
|
||||||
if (!Inventory)
|
if (!Inventory)
|
||||||
{
|
{
|
||||||
@@ -83,30 +125,18 @@ void APickupItem::Interact_Implementation(AActor* Interactor)
|
|||||||
}
|
}
|
||||||
|
|
||||||
const int32 Leftover = Inventory->AddItem(ItemData, Quantity, RemainingUses);
|
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
|
// Inventaire plein : on ne detruit surtout pas le tas, on retire seulement
|
||||||
// ce qui est rentre. Le joueur peut revenir chercher le reste.
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Retour temporaire, le temps que l'UI d'inventaire existe.
|
// Les anciens AddOnScreenDebugMessage ont disparu : en reseau ils
|
||||||
if (GEngine)
|
// 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
|
||||||
GEngine->AddOnScreenDebugMessage(-1, 2.f, FColor::Green,
|
// existe desormais et dit la meme chose au bon joueur.
|
||||||
FString::Printf(TEXT("+%d %s (total : %d, slots : %d/%d)"),
|
|
||||||
Added,
|
|
||||||
*ItemData->DisplayName.ToString(),
|
|
||||||
Inventory->GetItemCount(ItemData),
|
|
||||||
Inventory->GetUsedSlotCount(),
|
|
||||||
Inventory->GetTotalSlotCount()));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Leftover > 0)
|
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 "InventoryComponent.h"
|
||||||
#include "ItemDataAsset.h"
|
#include "ItemDataAsset.h"
|
||||||
#include "Kismet/GameplayStatics.h"
|
#include "Kismet/GameplayStatics.h"
|
||||||
|
#include "Net/UnrealNetwork.h"
|
||||||
|
|
||||||
#define LOCTEXT_NAMESPACE "StorageContainer"
|
#define LOCTEXT_NAMESPACE "StorageContainer"
|
||||||
|
|
||||||
@@ -22,6 +23,12 @@ AStorageContainer::AStorageContainer()
|
|||||||
PrimaryActorTick.bCanEverTick = true;
|
PrimaryActorTick.bCanEverTick = true;
|
||||||
PrimaryActorTick.bStartWithTickEnabled = false;
|
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"));
|
BaseMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("BaseMesh"));
|
||||||
SetRootComponent(BaseMesh);
|
SetRootComponent(BaseMesh);
|
||||||
|
|
||||||
@@ -166,15 +173,34 @@ void AStorageContainer::Interact_Implementation(AActor* Interactor)
|
|||||||
PlayerController->OpenStorage(this);
|
PlayerController->OpenStorage(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void AStorageContainer::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
|
||||||
|
{
|
||||||
|
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
|
||||||
|
|
||||||
|
DOREPLIFETIME(AStorageContainer, bOpen);
|
||||||
|
}
|
||||||
|
|
||||||
void AStorageContainer::SetOpen(bool bInOpen)
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
bOpen = bInOpen;
|
bOpen = bInOpen;
|
||||||
|
ApplyOpenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
void AStorageContainer::OnRep_Open()
|
||||||
|
{
|
||||||
|
ApplyOpenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
void AStorageContainer::ApplyOpenState()
|
||||||
|
{
|
||||||
if (USoundBase* Sound = bOpen ? OpenSound : CloseSound)
|
if (USoundBase* Sound = bOpen ? OpenSound : CloseSound)
|
||||||
{
|
{
|
||||||
UGameplayStatics::PlaySoundAtLocation(this, Sound, GetActorLocation());
|
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 "SurvivalStatsComponent.h"
|
||||||
|
|
||||||
#include "Engine/World.h"
|
#include "Engine/World.h"
|
||||||
|
#include "Net/UnrealNetwork.h"
|
||||||
#include "TimerManager.h"
|
#include "TimerManager.h"
|
||||||
|
|
||||||
USurvivalStatsComponent::USurvivalStatsComponent()
|
USurvivalStatsComponent::USurvivalStatsComponent()
|
||||||
{
|
{
|
||||||
// Pas de Tick : tout passe par un timer a basse frequence.
|
// Pas de Tick : tout passe par un timer a basse frequence.
|
||||||
PrimaryComponentTick.bCanEverTick = false;
|
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()
|
void USurvivalStatsComponent::BeginPlay()
|
||||||
{
|
{
|
||||||
Super::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;
|
Health = MaxHealth;
|
||||||
Hunger = MaxHunger;
|
Hunger = MaxHunger;
|
||||||
Thirst = MaxThirst;
|
Thirst = MaxThirst;
|
||||||
@@ -91,6 +137,15 @@ void USurvivalStatsComponent::UpdateStats()
|
|||||||
|
|
||||||
void USurvivalStatsComponent::ApplyRestore(float HealthAmount, float HungerAmount, float ThirstAmount)
|
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)
|
if (bDead)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -105,6 +160,13 @@ void USurvivalStatsComponent::ApplyRestore(float HealthAmount, float HungerAmoun
|
|||||||
|
|
||||||
void USurvivalStatsComponent::ApplyDamage(float Amount)
|
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)
|
if (bDead || Amount <= 0.f)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -367,6 +367,8 @@ private:
|
|||||||
* foyer. Se base sur le point de vue reel du pawn, donc sur ce qu'il voit a
|
* 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.
|
* l'ecran, et non sur la position de son corps.
|
||||||
*/
|
*/
|
||||||
|
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
|
||||||
|
|
||||||
int32 ResolveAimedSlot(const AActor* Interactor) const;
|
int32 ResolveAimedSlot(const AActor* Interactor) const;
|
||||||
|
|
||||||
/** Decide ce que ferait un appui sur Interagir, sans rien modifier. */
|
/** Decide ce que ferait un appui sur Interagir, sans rien modifier. */
|
||||||
@@ -455,8 +457,17 @@ private:
|
|||||||
/** Inventaire du pawn qui interagit, ou nullptr. */
|
/** Inventaire du pawn qui interagit, ou nullptr. */
|
||||||
static UInventoryComponent* GetInventoryOf(AActor* Interactor);
|
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;
|
TArray<FCookingSlotState> Slots;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -464,7 +475,12 @@ private:
|
|||||||
* qui brule ; quand il est consomme, tout le monde avance d'un cran et la
|
* qui brule ; quand il est consomme, tout le monde avance d'un cran et la
|
||||||
* derniere buche disparait du foyer.
|
* 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;
|
TArray<FFuelSlotState> Fuel;
|
||||||
|
|
||||||
FTimerHandle FuelTimer;
|
FTimerHandle FuelTimer;
|
||||||
@@ -477,5 +493,16 @@ private:
|
|||||||
UPROPERTY(Transient)
|
UPROPERTY(Transient)
|
||||||
TArray<TObjectPtr<UStaticMeshComponent>> FuelIndicators;
|
TArray<TObjectPtr<UStaticMeshComponent>> FuelIndicators;
|
||||||
|
|
||||||
|
UPROPERTY(ReplicatedUsing = OnRep_IsLit)
|
||||||
bool bIsLit = false;
|
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.
|
* @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")
|
UFUNCTION(BlueprintCallable, Category = "Crafting")
|
||||||
bool Craft(UCraftingRecipeDataAsset* Recipe);
|
bool Craft(UCraftingRecipeDataAsset* Recipe);
|
||||||
|
|
||||||
@@ -167,6 +175,10 @@ protected:
|
|||||||
TObjectPtr<UInventoryComponent> Inventory;
|
TObjectPtr<UInventoryComponent> Inventory;
|
||||||
|
|
||||||
private:
|
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
|
* Combien de postes de chaque type sont a portee, et non un simple
|
||||||
* "present / absent" -- voir AddAvailableStation.
|
* "present / absent" -- voir AddAvailableStation.
|
||||||
|
|||||||
@@ -76,6 +76,8 @@ public:
|
|||||||
// Called every frame
|
// Called every frame
|
||||||
virtual void Tick(float DeltaTime) override;
|
virtual void Tick(float DeltaTime) override;
|
||||||
|
|
||||||
|
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
|
||||||
|
|
||||||
// Called to bind functionality to input
|
// Called to bind functionality to input
|
||||||
virtual void SetupPlayerInputComponent(class UInputComponent* PlayerInputComponent) override;
|
virtual void SetupPlayerInputComponent(class UInputComponent* PlayerInputComponent) override;
|
||||||
|
|
||||||
@@ -487,6 +489,47 @@ protected:
|
|||||||
void UseSelectedItem();
|
void UseSelectedItem();
|
||||||
|
|
||||||
private:
|
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). */
|
/** Fait avancer toutes les animations de caméra (intégration uniquement). */
|
||||||
void UpdateCameraEffects(float DeltaTime);
|
void UpdateCameraEffects(float DeltaTime);
|
||||||
|
|
||||||
@@ -542,6 +585,8 @@ private:
|
|||||||
/** Vitesse verticale mémorisée pendant la chute, pour calibrer l'atterrissage. */
|
/** Vitesse verticale mémorisée pendant la chute, pour calibrer l'atterrissage. */
|
||||||
float LastFallSpeed = 0.f;
|
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;
|
bool bIsSprinting = false;
|
||||||
|
|
||||||
/** Évite de spammer le log si IA_Look est mal configurée. */
|
/** Évite de spammer le log si IA_Look est mal configurée. */
|
||||||
|
|||||||
@@ -103,6 +103,22 @@ public:
|
|||||||
UFUNCTION(BlueprintCallable, Category = "UI|Reglages")
|
UFUNCTION(BlueprintCallable, Category = "UI|Reglages")
|
||||||
void OpenSettings();
|
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:
|
protected:
|
||||||
virtual void BeginPlay() override;
|
virtual void BeginPlay() override;
|
||||||
virtual void OnPossess(APawn* InPawn) override;
|
virtual void OnPossess(APawn* InPawn) override;
|
||||||
@@ -389,9 +405,68 @@ private:
|
|||||||
UFUNCTION()
|
UFUNCTION()
|
||||||
void HandlePawnDied();
|
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();
|
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;
|
FTimerHandle RespawnTimerHandle;
|
||||||
|
|
||||||
/** Surveillance de l'eloignement, actif seulement pendant qu'un coffre est ouvert. */
|
/** 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;
|
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")
|
UFUNCTION(BlueprintCallable, Category = "Interaction")
|
||||||
void TryInteract();
|
void TryInteract();
|
||||||
|
|
||||||
@@ -93,7 +100,36 @@ protected:
|
|||||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interaction")
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interaction")
|
||||||
bool bDrawDebug = false;
|
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:
|
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. */
|
/** Relance le trace et met a jour FocusedActor. */
|
||||||
void UpdateFocus();
|
void UpdateFocus();
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ public:
|
|||||||
|
|
||||||
virtual void InitializeComponent() override;
|
virtual void InitializeComponent() override;
|
||||||
|
|
||||||
|
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ajoute des objets, en remplissant d'abord les piles entamees.
|
* Ajoute des objets, en remplissant d'abord les piles entamees.
|
||||||
* @param RemainingUses charges de l'exemplaire ajoute ; -1 signifie "plein".
|
* @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"))
|
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Inventaire|Barre rapide", meta = (ClampMin = "0"))
|
||||||
int32 HotbarSlotCount = 10;
|
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;
|
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:
|
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. */
|
/** Redimensionne le tableau si la taille cible a change. */
|
||||||
void EnsureSlotCount();
|
void EnsureSlotCount();
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ class USettingsMenuWidget;
|
|||||||
enum class EMenuPendingAction : uint8
|
enum class EMenuPendingAction : uint8
|
||||||
{
|
{
|
||||||
None,
|
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
|
StartGame
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -38,9 +44,21 @@ class SURVIVAL_PROJET_API AMainMenuPlayerController : public APlayerController
|
|||||||
public:
|
public:
|
||||||
AMainMenuPlayerController();
|
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")
|
UFUNCTION(BlueprintCallable, Category = "Menu")
|
||||||
void StartSoloGame();
|
void StartGame();
|
||||||
|
|
||||||
/** Ouvre la demande de confirmation. Ne quitte rien par elle-meme. */
|
/** Ouvre la demande de confirmation. Ne quitte rien par elle-meme. */
|
||||||
UFUNCTION(BlueprintCallable, Category = "Menu")
|
UFUNCTION(BlueprintCallable, Category = "Menu")
|
||||||
@@ -125,6 +143,25 @@ private:
|
|||||||
UFUNCTION()
|
UFUNCTION()
|
||||||
void HandleSettingsCloseRequested();
|
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. */
|
/** Ferme le jeu sans rien demander. */
|
||||||
void CloseGame();
|
void CloseGame();
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,14 @@ public:
|
|||||||
virtual void NativeConstruct() override;
|
virtual void NativeConstruct() override;
|
||||||
|
|
||||||
protected:
|
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))
|
UPROPERTY(meta = (BindWidget))
|
||||||
TObjectPtr<UButton> PlayButton;
|
TObjectPtr<UButton> PlayButton;
|
||||||
|
|
||||||
@@ -39,17 +46,6 @@ protected:
|
|||||||
UPROPERTY(meta = (BindWidgetOptional))
|
UPROPERTY(meta = (BindWidgetOptional))
|
||||||
TObjectPtr<UButton> SettingsButton;
|
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:
|
private:
|
||||||
UFUNCTION()
|
UFUNCTION()
|
||||||
void HandlePlayClicked();
|
void HandlePlayClicked();
|
||||||
|
|||||||
@@ -45,6 +45,18 @@ protected:
|
|||||||
UPROPERTY(meta = (BindWidgetOptional))
|
UPROPERTY(meta = (BindWidgetOptional))
|
||||||
TObjectPtr<UButton> SettingsButton;
|
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:
|
private:
|
||||||
UFUNCTION()
|
UFUNCTION()
|
||||||
void HandleResumeClicked();
|
void HandleResumeClicked();
|
||||||
@@ -54,4 +66,7 @@ private:
|
|||||||
|
|
||||||
UFUNCTION()
|
UFUNCTION()
|
||||||
void HandleQuitToMenuClicked();
|
void HandleQuitToMenuClicked();
|
||||||
|
|
||||||
|
UFUNCTION()
|
||||||
|
void HandleInviteClicked();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,10 +27,15 @@ class SURVIVAL_PROJET_API APickupItem : public AActor, public IInteractable
|
|||||||
public:
|
public:
|
||||||
APickupItem();
|
APickupItem();
|
||||||
|
|
||||||
|
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configure un pickup engendre a l'execution (objet jete au sol).
|
* Configure un pickup engendre a l'execution (objet jete au sol).
|
||||||
* A appeler entre SpawnActorDeferred() et FinishSpawning() pour que
|
* A appeler entre SpawnActorDeferred() et FinishSpawning() pour que
|
||||||
* OnConstruction ait deja les bonnes donnees et pose le bon mesh.
|
* 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")
|
UFUNCTION(BlueprintCallable, Category = "Item")
|
||||||
void InitPickup(UItemDataAsset* InItemData, int32 InQuantity, int32 InRemainingUses = -1);
|
void InitPickup(UItemDataAsset* InItemData, int32 InQuantity, int32 InRemainingUses = -1);
|
||||||
@@ -62,21 +67,44 @@ protected:
|
|||||||
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Item")
|
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Item")
|
||||||
TObjectPtr<USphereComponent> InteractionSphere;
|
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;
|
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;
|
int32 Quantity = 1;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Charges restantes pour un objet a usages multiples.
|
* 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.
|
* -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;
|
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.
|
* Reprend automatiquement le WorldMesh de l'ItemData dans l'editeur.
|
||||||
* Decoche pour imposer un mesh different sur une instance precise.
|
* 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 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 ---
|
// --- IInteractable ---
|
||||||
virtual FText GetInteractionPrompt_Implementation(AActor* Interactor) const override;
|
virtual FText GetInteractionPrompt_Implementation(AActor* Interactor) const override;
|
||||||
virtual void Interact_Implementation(AActor* Interactor) 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
|
* parce qu'il s'est trop eloigne. Un coffre qui deciderait seul finirait
|
||||||
* ouvert dans la moitie de ces cas.
|
* 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")
|
UFUNCTION(BlueprintCallable, Category = "Storage")
|
||||||
void SetOpen(bool bInOpen);
|
void SetOpen(bool bInOpen);
|
||||||
|
|
||||||
@@ -232,6 +250,11 @@ private:
|
|||||||
/** Cale la boite sur le volume combine des deux meshes, couvercle ferme. */
|
/** Cale la boite sur le volume combine des deux meshes, couvercle ferme. */
|
||||||
void FitInteractionBox();
|
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;
|
bool bOpen = false;
|
||||||
|
|
||||||
/** 0 = ferme, 1 = ouvert. Interpole, jamais pose directement. */
|
/** 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 BeginPlay() override;
|
||||||
virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) 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. */
|
/** Rend de la vie, de la faim et de la soif. Les valeurs sont bornees au max. */
|
||||||
UFUNCTION(BlueprintCallable, Category = "Survie")
|
UFUNCTION(BlueprintCallable, Category = "Survie")
|
||||||
void ApplyRestore(float HealthAmount, float HungerAmount, float ThirstAmount);
|
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. */
|
/** Appelee par le timer : fait descendre les jauges et applique les degats. */
|
||||||
void UpdateStats();
|
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;
|
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;
|
float Health = 0.f;
|
||||||
|
|
||||||
|
UPROPERTY(ReplicatedUsing = OnRep_Stats)
|
||||||
float Hunger = 0.f;
|
float Hunger = 0.f;
|
||||||
|
|
||||||
|
UPROPERTY(ReplicatedUsing = OnRep_Stats)
|
||||||
float Thirst = 0.f;
|
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;
|
bool bDead = false;
|
||||||
|
|
||||||
|
UFUNCTION()
|
||||||
|
void OnRep_Dead();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,9 +19,15 @@ public class Survival_projet : ModuleRules
|
|||||||
|
|
||||||
PrivateDependencyModuleNames.AddRange(new string[] { "Slate", "SlateCore" });
|
PrivateDependencyModuleNames.AddRange(new string[] { "Slate", "SlateCore" });
|
||||||
|
|
||||||
// Uncomment if you are using online features
|
// Coop Steam : on ne depend QUE de l'API generique (OnlineSubsystem) et de
|
||||||
// PrivateDependencyModuleNames.Add("OnlineSubsystem");
|
// ses helpers (OnlineSubsystemUtils, qui porte le resolveur d'adresses).
|
||||||
|
// Surtout PAS de OnlineSubsystemSteam en dur -- le code appellerait alors
|
||||||
// To include OnlineSubsystemSteam, add it to the plugins section in your uproject file with the Enabled attribute set to true
|
// 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" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,14 @@
|
|||||||
"TargetAllowList": [
|
"TargetAllowList": [
|
||||||
"Editor"
|
"Editor"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "OnlineSubsystemSteam",
|
||||||
|
"Enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "SteamSockets",
|
||||||
|
"Enabled": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user