Nous avons décidé de produire pour notre jeu de 3ème année un FPS extraction d'objectif en multijoueur (3 vs 3) avec une horde de mutants pour ralentir les deux équipes.
C'était un projet très challengeant car nous avons décidé d'implémenter la partie multijoueur en ECS, deux concepts que nous n'avions pas vus en cours.
Comme dit plus tôt, je n'avais jamais vu les ECS ni le multijoueur en cours. Par conséquent, avant le début du projet, j'ai dû produire une démo technique visant à m'initier à ces concepts. L'objectif de cette dernière était de synchroniser deux joueurs qui se feraient poursuivre par une horde de zombies, tout ça entièrement en ECS, pour cela je me suis servi de plusieurs packages gratuit de plusieurs packages gratuit d'Unity : Entities, Netcode for entities, Multiplayer Center et Multiplayer Play Mode.
N'ayant pas le droit a des packages payant pour le projet je suis donc partis sur un système d'animation hybride.
Nous sommes au début du projet, il faut produire rapidement un player controller avec les différentes mécaniques associées (saut, course, marche, escalade, accroupissement, glissade). Je me suis donc chargé de cette tâche.
Il doit être utilisable rapidement
La prise en main doit être satisfaisante lorsqu'on y joue
Pouvoir différencier les différents états
J'ai commencé par prendre des références de player controller d'autres jeux (Apex legend, Titanfall, CS GO, Half-Life 2). Je me suis rendu compte qu'ils étaient basés sur la physique de leur jeu et avaient, ce qui à eu pour consequence de créer des mécaniques de mouvement non prévues à la base (bunny up, air strafe, etc.). J'ai donc pris la décision d'utiliser la physique d'Unity dans le même but. Le player controller a 2 collier avec chacun un PhysicMaterial, un au niveau des pieds qui gère le slide et l'autre pour le reste du corps qui permettra de se coller au mur.
Pour gérer les différents états, j'ai mis en place une machine à état finie.
Ma prochaine tâche était de synchroniser la position des joueurs, la difficulté était que le player controller était un monobehavior et non un systeme ECS, pour y arriver je me suis inspiré des animations hybrides ECS/Gameobject, j'ai donc rattaché en local le player à un composant ECS, plus précisément un IInputComponentData, pour que le joueur puisse avoir l'autorité dessus, petit bémol : les données de ce composant sont bien transmises au serveur mais pas aux autres joueurs. Donc, pour faire en sorte que ça soit le cas, côté serveur je synchronise la position transmise par l'IInputComponentData au LocalTransform de l'entité et de cette façon tous les joueurs ont les informations.
Après quelques tentatives d'appliquer la logique ECS sur les mutants, nous avons préféré partir sur un système d'autorité partagée et d'utiliser les ECS que pour la partie réseau. Nous avons donc pris le choix d'utiliser le système de navmesh mis en place par Unity, ainsi que la synchronisation déjà utilisée pour le joueur. Chaque joueur contrôlera un certain nombre de mutants, et transmettra au serveur leur position et animation en cours.
Notre zombie attend, court, attaque et meurt, il se "réveille" quand il détecte un joueur devant lui, se fait tirer dessus ou un joueur récupère l'objectif. Concernant les points de vie du zombie, c'est le serveur qui a l'autorité dessus pour éviter des échanges inutiles
La carte faisant une taille assez conséquente, il fallait pouvoir contrôler le nombre de mutants dans chaque salle pour correspondre à la vision des graphistes. Pour cela j'ai développé un outil qui permet de mettre en place des zones d'influence divisées en 3 sous-zones, tout en faisant en sorte que ça reste le plus ergonomique possible pour les graphistes.
Après la synchronisation du joueur, il fallait mettre en place les différents éléments de gameplay ainsi qu'une condition de victoire/defaite.
Premièrement nous avons donnée l'autorité des points de vie aux joueurs. Pour éviter aux joueurs la frustration de voir leurs points de vie descendre sans savoir pourquoi à cause d'une mauvaise connexion de l'un des joueurs, j'ai donc eu une idée : Faire en sorte que le joueur touché simule le tir dans sa partie en local.
Voici le cheminement étape par étape :
1 - Joueur A touche joueur B
2 - Joueur A transmet au serveur le networkID du joueur B ainsi que les informations du tir (origine et direction)
3 - Le serveur renvoie l'information au joueur B touché graçe au networkID
4 - Le joueur B simule les informations du tir avec un raycast et si ce dernier le touche, il se retire des points de vie
Quand un joueur tombe à 0 points de vie, le gameobject qui le compose est désactivé, sauf la camera, de cette manière, le joueur voit ce qu'il se passe après sa mort, et quelques secondes plus tard il réapparaît à son point de spawn avec tous ses points de vie. Tout la partie réapparition est géré en local ce qui diminue les échanges avec le server.
Comme expliqué plus tôt, l'autorité des points de vie est gérée par le serveur, donc le joueur tire sur un mutant, il envoie une RPC avec une référence de l'entité touchée ainsi qu'un boolean pour savoir si la tête a été touchée. Quand ses points de vie tombent à 0 l'entité est détruite, le monobehaviour detecte qu'il n'est plus attaché à une entité, l'animation de mort se joue et un shader de disparition se lance.
L'objectif est quelque part sur la carte, quand un joueur le récupère les autres le voient sur le dos du player model et voient une icône sur l'HUD leur montrant où ils doivent emmener l'objectif (ou défendre si c'est l'ennemi qui le récupère), tous les mutants présents sur la carte sont alors enragés, ils ne sont plus en état d'attente et ne ciblent plus que le porteur de l'objectif, quand on arrive sur le point d'extraction avec le drapeau sur le dos, un écran de victoire apparaît et on gagne (et inversement).
Pour rendre visible le drapeau sur le player model, je l'ai mis dans la hiérarchie du joueur et quand il est récupéré il est activé (et inversement). Donc l'objectif sur la carte se désactive quand on le récupère et sa position en local se met à jour quand il est réactivé.
Comme c'était au début du projet, je ne savais à quoi ressemblait l'objectif. J'ai donc nommé tous les éléments de ce dernier en me basant sur un drapeau (capture de drapeau).
À la création du serveur, une entité avec uniquement un composant nommé "FlagComponent" sera créée. Il fera office de singleton. Quand un joueur récupère l'objectif il envoie un rpc contenant un bolean au serveur, ce dernier vérifie que c'est un changement d'état (pas ramassé ⇌ ramassé), si c'est le cas il met à jour ses donnée.
Le serveur récupère le networkID du client, avec il cherche l'entité qui contient la position du joueur, l'attache à ce dernier et envoie un rpc contenant l'entité à tous les joueurs, les autres joueurs reçoivent l'information, activent l'objectif sur le dos du joueur qui l'as ramassé ainsi que l'hud. Pour déposer le drapeau, c'est le même système sauf que le serveur renvoie la position et la rotation de l'objectif comme RPC.
Pour activer la condition de victoire ou de défaite, un autre système récupère la position du FlagComponent et envoie un RPC quand il arrive dans la bonne zone d'extraction (tout en vérifiant que le porteur soit de la bonne team).
public struct FlagComponent : IComponentData
{
public Entity owner;
public float3 position;
public quaternion rotation;
public bool isTake;
}
public struct FlagRPC : IRpcCommand
{
public bool isTaken;
}
public struct FlagOwnerRPC : IRpcCommand
{
public Entity flagOwner;
}
public struct FlagPositionRPC : IRpcCommand
{
public Vector3 position;
public quaternion rotation;
public Entity oldOwner;
}
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
partial struct FlagSystem : ISystem
{
public void OnCreate(ref SystemState state)
{
//Flag Creation
EntityCommandBuffer ecb = new EntityCommandBuffer(Allocator.Temp);
Entity flagEntity = ecb.CreateEntity();
ecb.AddComponent(flagEntity, new FlagComponent
{
owner = Entity.Null,
isTake = false
});
ecb.Playback(state.EntityManager);
ecb.Dispose();
state.RequireForUpdate<FlagComponent>();
}
public void OnUpdate(ref SystemState state)
{
if (SystemAPI.TryGetSingleton<FlagComponent>(out _))
{
EntityCommandBuffer ecb = new EntityCommandBuffer(Allocator.Temp);
Entity flagEntity = SystemAPI.GetSingletonEntity<FlagComponent>();
FlagComponent flagComponent = state.EntityManager.GetComponentData<FlagComponent>(flagEntity);
foreach (var (flagRPC, rpcCommandRequest, entityRpc) in SystemAPI.Query<RefRO<FlagRPC>, RefRO<ReceiveRpcCommandRequest>>().WithEntityAccess())
{
if (flagComponent.isTake != flagRPC.ValueRO.isTaken)
{
flagComponent.isTake = flagRPC.ValueRO.isTaken;
if (flagComponent.isTake)
{
// take flag
Entity rpcGetFlag = ecb.CreateEntity();
foreach (var (ghostOwner, player) in SystemAPI.Query<RefRO<GhostOwner>>().WithAll<PlayerSyncedData>().WithEntityAccess())
{
NetworkId playerTakerId = state.EntityManager.GetComponentData<NetworkId>(rpcCommandRequest.ValueRO.SourceConnection);
if (ghostOwner.ValueRO.NetworkId == playerTakerId.Value)
{
flagComponent.owner = player;
ecb.AddComponent(rpcGetFlag, new FlagOwnerRPC { flagOwner = player });
}
}
ecb.AddComponent(rpcGetFlag, new SendRpcCommandRequest());
}
else
{
//Drop flag
NetworkId playerCommand = state.EntityManager.GetComponentData<NetworkId>(rpcCommandRequest.ValueRO.SourceConnection);
GhostOwner flagOwner = state.EntityManager.GetComponentData<GhostOwner>(flagComponent.owner);
if (playerCommand.Value == flagOwner.NetworkId)
{
Entity rpcDropFlag = ecb.CreateEntity();
ecb.AddComponent(rpcDropFlag, new FlagPositionRPC
{
position = flagComponent.position,
rotation = flagComponent.rotation,
});
ecb.AddComponent(rpcDropFlag, new SendRpcCommandRequest());
flagComponent.owner = Entity.Null;
}
else
{
flagComponent.isTake = !flagRPC.ValueRO.isTaken;
}
}
}
SystemAPI.SetSingleton<FlagComponent>(flagComponent);
ecb.DestroyEntity(entityRpc);
}
if (flagComponent.owner != Entity.Null && flagComponent.isTake)
{
LocalTransform playerTransform = state.EntityManager.GetComponentData<LocalTransform>(flagComponent.owner);
flagComponent.position = playerTransform.Position;
flagComponent.rotation = playerTransform.Rotation;
SystemAPI.SetSingleton<FlagComponent>(flagComponent);
}
ecb.Playback(state.EntityManager);
ecb.Dispose();
}
}
}
Pendant un certain temps, je me suis penché sur le fait d'avoir plusieurs parties en même temps, ça a été compliqué et fastidieux car je ne savais pas comment m'y prendre. Dans un premier temps j'ai implémenté le package d'Unity Multiplayer service, et j'ai implémenté les sessions comme écrit dans la documentation, en espérant que ça arrive au résultat escompté, malheureusement c'est pas arrivée, j'ai du donc trouver une autre solution.
J'ai donc commencée à me balader dans la classe "ClientServerBootstrap", j'ai pu comprendre sur les grandes lignes comment il marchait et quelles fonctions il utilisait pour faire marcher le réseau, j'ai donc repris certaines de ses fonctions et mis en place le système actuel, un serveur principal qui crée d'autres serveur qui vont héberger la partie, une fois la partie finie les joueurs se reconnectent au serveur principal. Et j'ai couplé ça avec le système de lobby d'Unity pour que les joueurs puissent se rejoindre plus facilement.
[UnityEngine.Scripting.Preserve]
public class Bootstrap : ClientServerBootstrap
{
//bootstrap no longer manages the connection refer to the NetworkConnectionystem
public override bool Initialize(string defaultWorldName)
{
PlayType requestedPlayType = RequestedPlayType;
if (!DetermineIfBootstrappingEnabled())
{
return false;
}
if (requestedPlayType != PlayType.Client)
{
CreateServerWorld("MainServerWorld");
}
if (requestedPlayType != PlayType.Server)
{
CreateClientWorld("ClientWorld");
AutomaticThinClientWorldsUtility.BootstrapThinClientWorlds();
}
return true;
}
}
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
public partial class ServerSessionSystem : SystemBase
{
public ushort sessionPort;
bool isInitialized = false;
protected override void OnUpdate()
{
if (!isInitialized && sessionPort != 0 && World.Name != "MainServerWorld")
{
NetworkStreamDriver driver = SystemAPI.GetSingleton<NetworkStreamDriver>();
#if UNITY_EDITOR
driver.Listen(NetworkEndpoint.Parse("127.0.0.1", sessionPort));
#else
driver.Listen(NetworkEndpoint.Parse("51.210.104.120", sessionPort));
#endif
isInitialized = true;
//GUID Server sub scene
var sceneGuid = new Unity.Entities.Hash128("3ff0f8acaa208c34aa2308dd8ea7a45f");
// Load subscene (asynchrone)
SceneSystem.LoadSceneAsync(World.Unmanaged, sceneGuid, new SceneSystem.LoadParameters
{
AutoLoad = true
});
}
}
}
struct WorldInfo
{
public FixedString128Bytes sessionId;
public ushort port;
}
public struct CreateOrJoinNewServerWorld : IRpcCommand
{
public FixedString128Bytes sessionId;
}
public struct InfoPortConnection : IRpcCommand
{
public ushort connexionPort;
}