home..

Overstrike Arena

theme logo



Introduction

Overstrike Arena est un fps multijoueur, en 2 contre 2, où les joueurs s’affrontent dans une arène avec un “punch”, afin d’expulser leurs adversaires, et de marquer un but.

Page Steam : https://store.steampowered.com/app/1937550/Overstrike_Arena/

Equipe

Mon travail

J’ai un peu touché à tout le domaine technique pour osa, j’ai travaillé sur du gameplay en collaboration avec le 3C designer, sur du réseau, sur du système (mise en place des manches, du “flag”, des différentes équipes …), et sur quelques “tools” pour aider mon équipe à produire plus d’éléments.

Si vous êtes intéressés par la partie réseaux et tools, j’explique un peu plus bas des bouts de codes que j’ai utilisés pour développer OSA.


Technique

Index

Mirror

Pour ma première expérience dans le monde du multijoueur online, je voulais apprendre à utiliser une technologie gratuite, tout en me permettant d’acquérir une compréhension sur les API réseaux de haut niveau. Mon choix s’est porté sur Mirror, une API gratuite et open source.

Etant un jeux ou la vitesse, et la maitrise du personnage sont les clefs des mécanismes, nous avons choisi une librairie, Smooth Sync, ajoutant des scripts permettant une meilleure customisation, et de meilleurs performances sur la position des objets “online”.

Le jeux va donc utiliser une structure server/client, où le client possède l’autorité, et le server est un joueur, qui joue comme les autres clients.

Lobby

Pour la création du lobby, nous allons utiliser un template de NetworkManager que l’on va modifier. Le joueur décidant d’héberger, va attendre que les joueurs se connectent au server.

L’objectif du lobby dans notre jeu était de voir le pseudo des autres joueurs, de pouvoir changer d’équipe et de pouvoir se mettre “prêt”, pour lancer la partie.

Quand un joueur se connecte au server, il lui envoi un message contenant son pseudo.

public override void OnClientConnect(NetworkConnection conn)//Quand le client se connecte envoit un message contenant le pseudo
    {
        base.OnClientConnect(conn);

        //Keep player pseudo
        playerPseudo = GetComponent<MyNewNetworkAuthenticator>().lobbyPseudo;

        //Send message to host with client pseudo
        MyNewNetworkAuthenticator.ClientConnectionMessage clientMsg = new MyNewNetworkAuthenticator.ClientConnectionMessage 
        {
            pseudo = GetComponent<MyNewNetworkAuthenticator>().lobbyPseudo
        };
        NetworkClient.Send(clientMsg);
    }

Le serveur le reçoit, il crée un gameObject représentant le joueur, et lui transmet le pseudo reçu (Les objets synchronisés ne pouvant être créés que par le serveur), il l’identifie ensuite en tant que joueur, et le lie à une connexion. Le serveur met ensuite à jour, sa liste interne des joueurs connectés au lobby, très utile pour vérifier quand les joueurs seront prêts.


public override void OnStartHost() {
        NetworkServer.RegisterHandler<MyNewNetworkAuthenticator.ClientConnectionMessage>(CreateClientFromServer, true);
        NetworkServer.RegisterHandler<MyNewNetworkAuthenticator.CreateClientPlayer>(CreatePlayer, true);
    }

 private void CreateClientFromServer(NetworkConnection conn, MyNewNetworkAuthenticator.ClientConnectionMessage msg)
    {
        if (conn.clientOwnedObjects.Count < 1) // Débug quand le joueur se connecte à un server qui n'existe pas, et ensuite host
        {
            GameObject obj = Instantiate(lobbyPlayer);
            obj.GetComponent<LobbyPlayerLogic>().clientPseudo = msg.pseudo; //Créer le joueur lobby 
            obj.transform.position = new Vector3(0, 0, 0); // Peut importe la position, une fois le jeu lancé, le joueur est placé au spawn
            NetworkServer.AddPlayerForConnection(conn, obj);
            AddToServerArray(obj);
        }
        
    } //Spawn l'objet lobbyPlayer et configure le server

Le gameobject représentant un joueur, possède un networkTransform, ça lui permet de mettre à jour sa position à travers le réseau.

theme logo

Intéressons-nous aux fonctionnalités du “joueur lobby”.

Nous avons 3 variables déclarées comme des variables synchronisées. Une variable synchronisée, peut-être changée depuis le serveur, et réplique ce changement à travers tous les clients. On peut y rajouter un “hook”, pour pouvoir utiliser une fonction, à chaque fois que la variable change.

    [SyncVar(hook =nameof(setReadyUI))]
    public bool isReady = false;
    [SyncVar(hook = nameof(UpdateUsername))]
    public string clientPseudo;
    [SyncVar(hook = nameof(ChangeTeam))]
    public int team;

L’attribut Command, va déclencher la fonction de l’objet contrôlé par le client, sur l’objet contrôlé par ce client sur le serveur. Donc, si l’on combine cet attribut avec les variables synchrones, on peut répliquer un changement de variable depuis un client, sur tous les autres clients.

Les fonctions ButtonLeft et ButtonRight sont rattachées à un unityEvent, sur des boutons, et se déclenchent quand un joueur appuie sur une flèche. Ainsi quand un client appuie sur un bouton, il demande au serveur d’exécuter cette fonction, ce qui va changer une variable synchronisée et appeler sa fonction “hook”, qui va changer visuellement l’équipe du client, pour tous les joueurs.

[Command]
    public void ButonLeft()
    {
        if (!isReady)
        {
            if (team - 1 < 0)
            {
                team = 2;
            }
            else
            {
                team--;
            }
        }

    }
    [Command]
    public void ButonRight()
    {
        if (!isReady)
        {
            if (team + 1 > 2)
            {
                team = 0;
            }
            else
            {
                team++;
            }
        }
    }

    public void ChangeTeam(int oldValue,int newValue)
    {
        switch (newValue)
        {
            case 0:
                teamImage.sprite = omgegaImage;
                teamImage.color = omegaColor;
                teamName = TeamName.Red;
                break;
            case 1:
                teamImage.sprite = psiImage;
                teamImage.color = psyColor;
                teamName = TeamName.Blue;
                break;
            case 2:
                teamImage.sprite = spectatorImage;
                teamImage.color = spectatorColor;
                teamName = TeamName.Spectator;
                break;
        }
        if (hasAuthority)
        {
            serverManager.GetComponent<MyNewNetworkManager>().playerTeamName = teamName;
        }
        
    } //Syncronise l'ui

    [Command]
    public void CmdSetReady()
    {
        isReady = !isReady;
        serverManager.GetComponent<MyNewNetworkManager>().CheckIsReady();
    }

Afin de vérifier si chaque joueur est prêt, on va utiliser l’attribut command, pour vérifier si la variable isReady est “true” pour chaque client, si c’est le cas, on affiche un boutton “Start” , à l’host afin de pouvoir commencer une partie

     public bool CheckIsReady()
    {
        int redTeam = 0, blueTeam = 0;
        for (int i = 0; i < lobbyPlayerServer.Length; i++)
        {
            if (lobbyPlayerServer[i] != null)
            {
                if (lobbyPlayerServer[i].GetComponent<LobbyPlayerLogic>().teamName == LobbyPlayerLogic.TeamName.Blue)
                {
                    blueTeam++;
                }
                if (lobbyPlayerServer[i].GetComponent<LobbyPlayerLogic>().teamName == LobbyPlayerLogic.TeamName.Red)
                {
                    redTeam++;
                }

                if (!lobbyPlayerServer[i].GetComponent<LobbyPlayerLogic>().isReady)
                {
                    StartButton.SetActive(false);
                    return false;
                }
            }
        }

        if((redTeam == blueTeam) || (redTeam == 0 && blueTeam == 1) || (blueTeam == 0 && redTeam == 1) )
        {
            StartButton.SetActive(true);
            return true;
        }

        return false;

    }

On donc réussit à avoir notre joueur répliquer, avec un pseudo qui est lisible par tout le monde.

theme logo

Match

Une fois que l’host lance la partie, chaque client doit charger la nouvelle map. Il doit attendre que chaque joueur finisse de charger le monde, pour commencer une partie. Mirror nous offre une booléenne pour connaître l’état du joueur isReady. Si la variable est égale à false, le joueur est en train de charger. Au chargement de la map, l’host va donc attendre que tous les clients aient fini de charger, avant “d’activer” le joueur, et de commencer une partie.

Les attributs Server et ServerCallback, vont permettre de spécifier des fonctions qui ne peuvent être lancées que par le serveur. Etant donné que c’est le serveur qui attend les joueurs, ces fonctions ne peuvent être qu’utilisées par lui.

On parcourt ici les valeurs d’un dictionnaire disponible côté serveur,

 [ServerCallback]
    private bool AllClientAreReady()
    {
        foreach( NetworkConnectionToClient conn in NetworkServer.connections.Values)
        {
            if (!conn.isReady)
            {
                return false;
            }
        }
        return true;
    }

    [Server]
    private void ActivatePlayer()
    {
        foreach (NetworkConnectionToClient conn in NetworkServer.connections.Values)
        {
            foreach (NetworkIdentity idOwnedByClient in conn.clientOwnedObjects)
            {
                if (idOwnedByClient.gameObject.GetComponent<PlayerLogic>() != null)
                {
                    idOwnedByClient.gameObject.GetComponent<PlayerLogic>().RpcRespawn(conn,3f);
                }
            }
        }
    }

Notre jeu se divise en manches. Une manche commence après qu’un compteur se soit déclenché.

theme logo

Elle se termine quand une équipe marque un but.

theme logo

Toute la gestion de la partie est géré grâce à mon GameObject “MatchManager”.

theme logo

Cet objet va gérer, le début et la fin de la partie, garder en mémoire les scores en bref manager le déroulement d’une partie entière.

    [Server]
    public void RpcEndGame(string text)
    {
        foreach (NetworkConnectionToClient conn in NetworkServer.connections.Values)
        {
            foreach (NetworkIdentity idOwnedByClient in conn.clientOwnedObjects)
            {
                if (idOwnedByClient.gameObject.GetComponent<PlayerLogic>() != null)
                {
                    idOwnedByClient.gameObject.GetComponent<PlayerLogic>().RpcEndGame(conn, text);
                }
            }
        }
    }

     [Server]
    private void ChangeUiPlayer(string text)
    {
        foreach (NetworkConnectionToClient conn in NetworkServer.connections.Values)
        {
            foreach (NetworkIdentity idOwnedByClient in conn.clientOwnedObjects)
            {
                if (idOwnedByClient.gameObject.GetComponent<PlayerLogic>() != null && !idOwnedByClient.gameObject.GetComponent<PlayerLogic>().isSpawning)
                {
                    //Creér une coroutile affichant l'ui, pui créer une nouvelle coroutine permettant de faire respawn les joueurs
                    idOwnedByClient.gameObject.GetComponent<PlayerLogic>().RpcShowGoal(conn,text);
                }
            }
        }
    }

On va utiliser des coroutines afin de créer une chronologie sur les actions liées au network, et de ne pas bloquer l’affichage de l’ui du joueur.

    [TargetRpc]
    public void RpcShowGoal(NetworkConnection conn,string text)
    {
        timerToStart = NetworkTime.time;
        if(respawnCor == null)
        {
            respawnCor = StartCoroutine(GoalMessageManager(text));
        }
            
        CmdShowScoreHud();
    }

    //Gère le respawn du joueur, situé dans le script du joueur
    public IEnumerator RespawnManager()
    {
        //Uniquement possible pour le joueur possédant l'autorithé
        if (hasAuthority)
        {
            //Stop le punch
            StopChargingPunch();
            selfMovement.FPVAnimator.StopAnimatePunch();

            actionExclusiveHud.SetActive(false);

            //Transition caméra au plafond
            if (overviewCameraPos == null)
                overviewCameraPos = GameObject.Find("OverviewCameraPosBlueSide").transform;
            overviewCamera.enabled = true;
            highlightCam.enabled = false;
            fpvCam.enabled = false;
            mainCam.enabled = false;
            
            //On a créer un Template Scène avec un Spawner, et un nombre d'enfant correspondant au des points dans l'espace
            Transform spawnPoint;
            spawnPoint = GameObject.FindWithTag("Spawner").transform.GetChild(spawnPosition);

            //Fade out de l'écran de chargement
            if (loadingScreen.color.a == 1)
            {
                loadingScreen.CrossFadeAlpha(0, 0.5f, false);
            }

            //Reset la position du joueur
            transform.position = spawnPoint.position; //Obligatoire, sinon ne trouve pas le spawner à la premirèe frame
            selfCollisionParent.transform.localRotation = spawnPoint.rotation;
            selfCamera.localRotation = spawnPoint.rotation;

            //Téléporte le joueur à travers le réseaux (Important, sinon peut laisser le joueur s'encastrer dans des murs)
            selfSmoothSync.teleportOwnedObjectFromOwner();
            selfCollsionSmoothSync.teleportOwnedObjectFromOwner();

            //Reset la rotation du joueur
            Quaternion startRot = selfCamera.localRotation;
            xRotation = startRot.eulerAngles.x;
            yRotation = startRot.eulerAngles.y;

            //Créer l'affichage du timer
            hudTextPlayer.gameObject.SetActive(true);

            //Lock caméra
            isSpawning = true;

            //Bloque le joueur et affiche le temps restant avant le respawn
            while (NetworkTime.time - timerToStart <= timerMaxToStart)
            {
                selfMovement.ResetVelocity();
                selfMovement.ResetVerticalVelocity();
                hudTextPlayer.text = System.Math.Ceiling(timerMaxToStart -(NetworkTime.time - timerToStart)).ToString();
                if (tryToRespawn)
                {
                    timerToStart = NetworkTime.time;
                    tryToRespawn = false;
                }
                overviewCamera.transform.position = overviewCameraPos.transform.position;
                overviewCamera.transform.rotation = overviewCameraPos.transform.rotation;

                yield return new WaitForEndOfFrame();

            }
            //La partie reprend pour le joueur
            roundStarted = true;
            hudTextPlayer.gameObject.SetActive(false);
            actionExclusiveHud.SetActive(true);

            //délock la camera
            isSpawning = false;
            overviewCamera.enabled = false;
            highlightCam.enabled = true;
            fpvCam.enabled = true;
            mainCam.enabled = true;

            //ajuste la rotation de la caméra
            xRotation = startRot.eulerAngles.x;
            yRotation = startRot.eulerAngles.y;

            //Initialise le joueur dans l'outil d'analyse
            if (GameObject.Find("Analytics") != null)
            {
                GameObject.Find("Analytics").GetComponent<PA_Position>().startWrite = true;
            }
            respawnCor = null;
        }
    }

Outil et Analyse

Durant mon travail sur ce projet, j’ai dû créer quelques outils pour pouvoir répondre à certaines attentes des games designers. On voulait par exemple pouvoir analyser le déplacement des joueurs que l’on faisait tester.

J’ai donc créé poorAnalytics, un petit outil qui me permet de dessiner le déplacement des joueurs en fonction d’une manche.

theme logo

theme logo

theme logo

On peut décomposer cet outil en 2 scripts, un Writer, un Reader.

Le “writer”, va écrire dans un fichier text, toutes les positions des objets stockés dans la liste analyticGameObjectPosition; Afin de pouvoir utiliser ces données plus tard, je mets en place une architecture simple dans le fichier texte :

    public class PA_Position : MonoBehaviour
    {
        public MatchManager gameManager;
        public string fileToStorePosition;

        public float timeEachBreak;
        private float ownDeltaTime;

        public List<Transform> analyticGameObjectPosition;

        private bool onceInit = true;
        [HideInInspector]
        public bool startWrite = true;

        private void Start()
        {
            if (GameObject.Find("ServerManager").GetComponent<MyNewNetworkManager>().analyticsPath != string.Empty)
            {
                fileToStorePosition = GameObject.Find("ServerManager").GetComponent<MyNewNetworkManager>().analyticsPath;
            }
            else
            {
                gameObject.SetActive(false);
            }
            
        }

        void InitAnalytics()
        {
            StreamWriter writer = new StreamWriter(fileToStorePosition, false);
            writer.Write("// ");
            for (int i = 0; i < analyticGameObjectPosition.Count; i++)
            {
                writer.Write("Object " + i.ToString() + "-");
            }
            writer.WriteLine();
            writer.Close();
        }

        // Update is called once per frame
        void Update()
        {
            if (gameManager.startGame)
            {
                if (onceInit)
                {
                    onceInit = false;
                    InitAnalytics();
                }

                ownDeltaTime += Time.deltaTime;
                if (ownDeltaTime >= timeEachBreak && startWrite)
                {
                    WriteAllObjectPosition();
                    ownDeltaTime = 0f;
                }
            }
            
        }

        void OnApplicationQuit()
        {
            StreamWriter writer = new StreamWriter(fileToStorePosition, true);
            DateTime dt = DateTime.Now;
            writer.WriteLine("// " + dt.ToString() + " //");
            writer.Close();
        }

        private void WriteAllObjectPosition()
        {
            StreamWriter writer = new StreamWriter(fileToStorePosition, true);
            foreach(Transform objPosition in analyticGameObjectPosition)
            {
                writer.Write(objPosition.position.x.ToString() + "|" + objPosition.position.y.ToString() + "|" + objPosition.position.z.ToString() + ";");
            }
            writer.Write("\n");
            writer.Close();
        }

        public void WriteNewRound()
        {
            startWrite = false;
            StreamWriter writer = new StreamWriter(fileToStorePosition, true);
            writer.Write("++New Round++");
            writer.Write("\n");
            writer.Close();
        }
    }

Le “Reader”, récupère le path du fichier texte, et grâce à un bouton (Draw in world), va lancer la fonction LoadKeyPositions, qui s’occupe de parser et de stocker les positions dans une liste 3 dimensions. Chaque dimension correspondant à un des paramètres de la partie -> Quelle manche, Quel joueur, ses positions.

public List<List<List<Vector3>>> playersKeyPosition = new List<List<List<Vector3>>>();

 public void LoadKeyPositions()
    {

        playersKeyPosition.Clear();
        roundPointeur = 0;
        round = 0;
        try
        {
            for (int i = 0; i < 5; i++)
            {
                playersKeyPosition.Add(new List<List<Vector3>>());
            }

            StreamReader sr = new StreamReader(pathFilePositions);

            string firstLine = sr.ReadLine();
            string[] allPlayersInLine = firstLine.Split(new[] { '-' }, StringSplitOptions.RemoveEmptyEntries);
            
            //Colors add or remove
            if(colors.Count > allPlayersInLine.Length)
            {
                while(colors.Count != allPlayersInLine.Length)
                {
                    colors.RemoveAt(colors.Count-1);
                }
            }
            if (colors.Count < allPlayersInLine.Length)
            {
                while (colors.Count != allPlayersInLine.Length)
                {
                    colors.Add(new Color());
                }
            }

            foreach (string players in allPlayersInLine)
            {
                if(players.Length > 1)
                {
                    for(int i = 0; i < 5; i++)
                    {
                        playersKeyPosition[i].Add(new List<Vector3>());
                    }
                }
            }
            
            //Read and parse file position
            while (!sr.EndOfStream)
            {
                string line = sr.ReadLine();

                if (line.Contains("++"))
                {
                    roundPointeur++;
                }
                if (!line.Contains("//") && !line.Contains("++"))
                {
                    //Find each object
                    string[] playersPositions = line.Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries);

                    //Parse position for an object
                    for(int i = 0; i < playersPositions.Length; i++)
                    {
                        string[] playerPosition = playersPositions[i].Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries);
                        float _x = float.Parse(playerPosition[0]);
                        float _y = float.Parse(playerPosition[1]);
                        float _z = float.Parse(playerPosition[2]);

                        playersKeyPosition[roundPointeur][i].Add(new Vector3(_x, _y, _z));
                    }
                }
                
            }
            sr.Close();
        }catch(Exception ex)
        {
            Debug.Log(ex.ToString());
        }
    }

Une fois les données stockées, on va implémenter une fonction apportée par Monobehavior : OnDrawnGizmos. Cette fonction va nous permettre de dessiner des formes simples directement dans la scène du moteur de jeux. Une fonction très utile pour le debugging ou l’analyse du jeu. On va alors dessiner une ligne entre les coordonnées de chaque joueur en fonction de la manche, indiqué par un entier depuis l’inspector.


 void OnDrawGizmos()
    {

        //Gizmos.DrawLine(new Vector3(0,0,0), new Vector3(0, 100, 0));
        int j = 0;

        if(playersKeyPosition.Count > 0)
        {
            foreach (List<Vector3> KeyPositions in playersKeyPosition[round])
            {
                Gizmos.color = colors[j];
                if (KeyPositions.Count > 3) // sup to 3 because there is min 2 lines per files
                {
                    for (int i = 1; i < KeyPositions.Count; i++)
                    {
                        Gizmos.DrawLine(KeyPositions[i - 1], KeyPositions[i]);
                    }
                }
                j++;
            }
        }

    }

On utilise ici un fichier texte pour stocker les données afin de pouvoir l’échanger, le stocker, le récupérer plus facilement. On peut aussi essayer d’automatiser l’envoi de ce fichier sur un server. À la fin d’une partie, l’hôte pourrait envoyer automatiquement le texte sur un server que l’on héberge, ça permettrait à mon équipe d’avoir facilement accès à toutes les données des joueurs ayant essayé le jeu online.

Il y a beaucoup de moyens pour améliorer cet outil, l’utilisation d’une liste 3d, n’est vraiment pas une bonne pratique en informatique par exemple. Un onceInit, totalement inutile, qui peut-être remplacé par la création d’une coroutine au start, qui se met en attente, tant que les conditions requises ne sont pas terminés.

© 2024 Oses Jean-Baptiste   •  Powered by Soopr   •  Theme  Moonwalk