Équipe : 8 personnes

Durée : 8 semaines

Technologie : Unity (C# - ECS)

Tâches effectuées :
  • - Création et gestion des sessions en multijoueur (ECS)
  • - Comportement des mutants ainsi que leurs animations
  • - Création et synchronisation en réseau des principaux
  • éléments de gameplay (ECS)
  • - Création d'un player controller ainsi que sa synchronisation
  • - Mise en place et syncronisation en reseau de tous les
  • sons et musiques à l'aide de Wwise
  • - Mise en place du tutoriel
  • - Mise en place des menus et des éléments du HUD
  • Outils développés :
  • - Création d'un outil permettant le chargement de
  • plusieurs scènes en asynchrone
  • - Création d'un outil pour contrôler l'apparition des
  • mutants

Présentation Technique

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.

Préproduction

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.

Production

Player controller

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.

Problématiques

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.

Synchronisation du joueur

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.

Comportements de mutants

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

Outil de gestion de mutants

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.

Principaux elements de gameplay

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.

Tir entre joueurs

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.

Tirs sur les mutants

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.

Objectif et condition victoire / défaite

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();
        }
    }
}

Session multijoueur

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 NetworkConnectionServerSystem : SystemBase
{
    private ushort mainServPort = 7979;
    private ushort nextServPort;

    private List<WorldInfo> allServWorlds = new List<WorldInfo>();

    bool isInitialized = false;
    protected override void OnCreate()
    {

        if (World.Name == "MainServerWorld")
        {
            nextServPort = mainServPort;
            nextServPort++;
        }
        else
        {
            isInitialized = true;
        }

        base.OnCreate();
    }

    protected override void OnUpdate()
    {
        if (!isInitialized)
        {
            NetworkStreamDriver driver = SystemAPI.GetSingleton<NetworkStreamDriver>();

#if UNITY_EDITOR
            driver.Listen(NetworkEndpoint.Parse("127.0.0.1", mainServPort));
#else
            driver.Listen(NetworkEndpoint.Parse("51.210.104.120", mainServPort));
#endif

            isInitialized = true;

            Debug.Log("[MAIN SERVER] LISTEN");
        }
        else if (World.Name == "MainServerWorld")
        {
            EntityCommandBuffer ecb = new EntityCommandBuffer(Allocator.Temp);

            foreach (var (rpc, receiveCommand, entityRpc) in SystemAPI.Query<RefRO<CreateOrJoinNewServerWorld>, RefRO<ReceiveRpcCommandRequest>>().WithEntityAccess())
            {
                Entity rpcToClient = ecb.CreateEntity();

                bool worldExist = false;
                ushort port = nextServPort;

                Debug.Log("[NetworkConnectionServerSystem::Update] - Receive rpc create world");

                if (rpc.ValueRO.sessionId != "Tutorial")
                {
                    foreach (WorldInfo world in allServWorlds)
                    {
                        if (world.sessionId == rpc.ValueRO.sessionId)
                        {
                            worldExist = true;
                            port = world.port;
                        }
                    }
                }

                if (!worldExist)
                {
                    WorldInfo newWorld = new WorldInfo();

                    newWorld.port = nextServPort;
                    newWorld.sessionId = rpc.ValueRO.sessionId;

                    CreateServerSession(newWorld.port);

                    allServWorlds.Add(newWorld);
                    nextServPort++;
                }

                ecb.AddComponent(rpcToClient, new InfoPortConnection { connexionPort = port });
                ecb.AddComponent(rpcToClient, new SendRpcCommandRequest { TargetConnection = receiveCommand.ValueRO.SourceConnection });

                ecb.DestroyEntity(entityRpc);
            }

            ecb.Playback(EntityManager);
            ecb.Dispose();
        }
    }


    private World CreateServerSession(ushort port)
    {
        World servWorld = ClientServerBootstrap.CreateServerWorld("ServerWorld " + allServWorlds.Count);
        ServerSessionSystem sessionSystem = servWorld.GetExistingSystemManaged<ServerSessionSystem>();

        sessionSystem.sessionPort = port;

        Debug.Log("Create a new world port " + port);

        return servWorld;
    }

}
#if !UNITY_SERVER
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
public partial class ClientConnectionSystem : SystemBase
{
    public static ushort sessionPort;
    private ushort mainServPort = 7979;

    bool isInitialized = false;
    bool test = true;

    float timerTimeout = 10f;
    float timeTimeout = 10f;

    protected override void OnUpdate()
    {
        if (!isInitialized)
        {
            NetworkStreamDriver driver = SystemAPI.GetSingleton<NetworkStreamDriver>();

#if UNITY_EDITOR
            driver.Connect(EntityManager, NetworkEndpoint.Parse("127.0.0.1", mainServPort));
#else
            driver.Connect(EntityManager, NetworkEndpoint.Parse("51.210.104.120", mainServPort));
#endif
            sessionPort = mainServPort;
            
            isInitialized = true;
        }
        else
        {
            if (!SystemAPI.TryGetSingleton<NetworkStreamConnection>(out _))
            {
                NetworkStreamDriver driver = SystemAPI.GetSingleton<NetworkStreamDriver>();
#if UNITY_EDITOR
                driver.Connect(EntityManager, NetworkEndpoint.Parse("127.0.0.1", sessionPort));
#else
                driver.Connect(EntityManager, NetworkEndpoint.Parse("51.210.104.120", sessionPort));
#endif
                Debug.LogError("[ClientConnectionSystem::Update] - NetworkStreamConnection entity not found try to reconect port " + sessionPort);
                Game.Instance.connected = false;
            }
            else if (!SystemAPI.TryGetSingleton<NetworkId>(out _))
            {
                Debug.LogWarning("[ClientConnectionSystem::Update] - NetworkId entity not found time out in " + timerTimeout);
                timerTimeout -= SystemAPI.Time.DeltaTime;

                if (timerTimeout < 0)
                {
                    EntityCommandBuffer ecb = new EntityCommandBuffer(Allocator.Temp);
                    Entity connectionEntity = SystemAPI.GetSingletonEntity<NetworkStreamConnection>();

                    ecb.AddComponent<NetworkStreamRequestDisconnect>(connectionEntity);
                    timerTimeout = timeTimeout;

                    ecb.Playback(EntityManager);
                    ecb.Dispose();
                }

                Game.Instance.connected = false;
            }
            else
            {
                EntityCommandBuffer ecb = new EntityCommandBuffer(Allocator.Temp);
                bool hasConnection = EntityManager.CreateEntityQuery(typeof(NetworkStreamInGame)).CalculateEntityCount() > 0;
                //automatic go in game
                if (mainServPort != sessionPort && !hasConnection)
                {
                    Entity connectionEntity = SystemAPI.GetSingletonEntity<NetworkStreamConnection>();
                    Entity rpcEntity = ecb.CreateEntity();

                    ecb.AddComponent<NetworkStreamInGame>(connectionEntity);

                    ecb.AddComponent(rpcEntity, new InGameStateRequest());
                    ecb.AddComponent(rpcEntity, new SendRpcCommandRequest());
                    Game.Instance.connected = true;

                }

                foreach (var (rpc, entityRpc) in SystemAPI.Query<RefRO<InfoPortConnection>>().WithAll<ReceiveRpcCommandRequest>().WithEntityAccess())
                {
                    Entity connectionEntity = SystemAPI.GetSingletonEntity<NetworkStreamConnection>();

                    ecb.AddComponent<NetworkStreamRequestDisconnect>(connectionEntity);
                    sessionPort = rpc.ValueRO.connexionPort;

                    ecb.DestroyEntity(entityRpc);
                }

                if (Game.Instance.connectMainServ)
                {
                    Entity connectionEntity = SystemAPI.GetSingletonEntity<NetworkStreamConnection>();

                    Game.Instance.teamWin = -1;
                    Game.Instance.connectMainServ = false;
                    Game.Instance.playerTeam = 0;

                    Game.Instance.flagOwner = null;
                    Game.Instance.playerList.Clear();

                    sessionPort = mainServPort;
                    ecb.AddComponent<NetworkStreamRequestDisconnect>(connectionEntity);
                }
                timerTimeout = timeTimeout;

                ecb.Playback(EntityManager);
                ecb.Dispose();
            }
        }
    }

}
#endif
[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;
}