Créer un jeu multi-joueur avec Unity 4.x – Partie 1

Si il y a une fonctionnalité qui fait rêver un tas de développeurs de jeux vidéo, c’est bien l’aspect multi-joueur ! Cependant cette partie du développement n’est pas la chose la plus simple à appréhender, surtout lorsque l’on commence à créer des jeux. Heureusement Unity propose par défaut un module réseau assez bien fait, qui une fois prst en main se révèle assez facile à utiliser. Aujourd’hui nous allons voir comment créer un petit jeu multi-joueur basique avec Unity 4.x*, ce tutoriel sera par ailleurs découpé en plusieurs parties.

* il y a des changements dans la version 5.x, je mettrais à jour ou referais un article au besoin.

Pour cette première partie nous utiliserons la GUI d’Unity 4.4 et pas celle de la version 4.6 car nous voulons aller vite, cependant dans la dernière partie du tutoriel, je vous montrerai comment réaliser un menu complet avec ce nouveau module !

J’ai choisi de créer un jeu avec des tanks car c’est facile à exploiter et ça s’adapte très bien au cas du jeu en réseau. A la fin du tutoriel vous devriez avoir quelque chose comme ça si vous utilisez le pack de ressources proposé. J’ai baptisé ce titre : Mega Tanks Destroyers : Network Edition (comment ça c’est kitch ?!)

tutonet_preview
Voilà à quoi ressemblera notre « création »

1. Mise en place du projet

Cette partie n’a strictement rien à voir avec le réseau, mais comme je veux que nous partions sur de bonnes bases, je vais m’attarder 5 minutes sur la mise en place du projet. Pour commencer nous allons créer tout de suite un nouveau projet, j’ai appelé le miens NetworkTutorial. Je vous invite à créer la structure de dossiers suivante :

  • Materials
  • Models
  • Prefabs (contiendra les prefabs des joueurs)
  • Scenes (Le menu et la scène de jeu)
  • Scripts
  • Textures

1.1. Dossier Models

Dans cet exemple le joueur dirige un tank, on y retrouve ainsi le modèle correspondant. Vous pouvez récupérer le tank dans le pack de ressources du tutoriel.

1.2. Dossier Scenes

Je vous invite à créer directement 2 nouvelles scènes que l’on nommera Menu et GameLevel. La scène Menu sera chargée d’afficher le menu qui permet de créer ou rejoindre une partie. Enfin la scène GameLevel contiendra le niveau jouable du jeu.

1.3. Scripts

En réalité nous n’avons pas besoin de beaucoup de scripts, je vous l’ai dit, le réseau avec Unity est assez facile à implémenter. Cependant pour des raisons de confort, nous allons en ajouter quelques uns.

  1. CameraFollow.cs*
  2. LevelManager.cs
  3. NetworkManager.cs
  4. SpawnPoint.cs*
  5. TankController.cs

Voilà 5 scripts c’est tout ! Nous avons un script qui commande les déplacements et rotations de la caméra par rapport au joueur. LevelManager sera chargé d’ajouter des joueurs au niveau quand ils seront connectés. Le script NetworkManager aura un double usage, il sera uniquement utilisé dans le menu et affichera les boutons permettant de se connecter ou rejoindre une partie. Pour des raisons de confort et pour faire les choses bien, SpawnPoint,  indiquera dans l’éditeur où sont les points de démarrage des joueurs, le joueur prendra la couleur de ce point. Enfin TankController permet de contrôler le joueur courant (il y aura du code réseau ici aussi).

* Vous devez récupérer le contenu des scripts marqué avec une étoile en cliquant sur les liens car je ne reviendrais pas dessus dans ce tutoriel. Si vous avez récupéré le pack alors tout est déjà dedans 😉

1.4. Textures & modèle 3D

Vous êtes libre de faire comme bon vous semble, soit vous prenez le temps de créer un niveau, soit vous téléchargez ce pack qui contient les textures que j’ai utilisées, ainsi que le modèle du tank. Si vous optez pour la solution du pack à télécharger alors vous n’avez qu’à copier le tout dans votre projet Unity (avec les .meta).

 1.5. Configuration du projet

Enfin comme nous créons un jeu qui se joue à plusieurs, il faut que l’on puisse le tester en ouvrant deux fois le jeu (logique non ?). Pour que le réseau fonctionne sur le même PC en lançant deux fois le jeu, il faut aller dans le menu Edit > Project Settings > Player puis cocher Run in Background dans le menu Resolution And Presentation.

2. Créer et rejoindre une partie

Nous voilà fin prêt pour commencer notre aventure dans le monde merveilleux du jeu à plusieurs avec Unity. Avant de commencer voilà un peu de théorie accélérée. Dans un jeu en réseau il y a deux types d’entités : Le serveur et les clients. Le serveur héberge la partie, il est le maître absolu, si il se déconnecte la partie n’existe plus. Les clients sont les joueurs qui se connectent au serveur sur une partie donnée. Si un client se déconnecte ce n’est pas grave car c’est le serveur qui commande. Notre objectif est de donner la possibilité à un joueur d’être soit serveur, dans ce cas il créera une instance d’un serveur, soit d’être client et dans ce cas il rejoindra une instance d’un serveur. C’est terminé, passons à la suite !

Ouvrez la scène Menu et attachez le script NetworkManager sur la caméra. Nous voulons afficher un menu au joueur pour qu’il puisse au choix, créer une nouvelle partie ou alors en rejoindre une existante. La création d’une nouvelle partie impliquera la création d’un serveur. Pour cela nous allons utiliser l’objet MasterServer ! C’est un serveur en ligne hébergé par Unity, sachez cependant que vous pouvez en exécuter un localement sur votre machine. Voici un peu de code !

using UnityEngine;
using System.Collections;

public class NetworkManager : MonoBehaviour 
{	
    public const string TypeName = "MyGameTitle";
    public static string GameName = "GameName";
    public static HostData GameToJoin = null;
    private HostData[] _hostData;

    	// ....
}

On commence par déclarer quelques variables qui vont nous être utiles par la suite. TypeName est très importante car elle définit l’identifiant de votre jeu sur le MasterServer. Etant donné que nous utilisons une instance du MasterServer en ligne (hébergé chez Unity rappelez vous), il faut que le nom soit efficace, et surtout unique 😉 GameName correspond au nom de la partie qui va être crée. Dans Counter Strike par exemple quand vous créez une partie, le TypeName est CounterStrike et le GameName est le nom de la partie que vous venez de créer.

La variable GameToJoin va nous servir à stocker l’objet HostData de la partie, cet objet contient toutes les informations d’une partie (nombre de joueurs connecté, adresse IP, port, son GUID, etc…). Il est fourni par le serveur. Tous les clients devront se servir de cette variable pour rejoindre la parties lorsque nous changerons de scène via Application.LoadLevel().

Enfin le tableau de HostData va contenir toutes les parties en cours pour votre jeu. Typiquement quand un client rafraîchira la liste des parties disponible, ce tableau sera mis à jour.

Maintenant la meilleur partie : L’UI avec le module d’Unity < 4.6 (oui car il faut souffrir pour être beau pour aller vite).

tutonet_parties
Voilà notre menu
private Rect _startBtnRect;
private Rect _joinBtnRect;
private Rect _cacheRect;

void Start()
{
	  // Rectangles pour les boutons
	  _startBtnRect = new Rect(
		    Screen.width - 250, 
		    Screen.height / 2 - 35, 200, 50);
		
	
	  _joinBtnRect = new Rect(
		    Screen.width - 250, 
		    Screen.height / 2 + 35, 200, 50);
		
	  _cacheRect = new Rect(0, 0, 200, 50);
}

void OnGUI()
{
	  GUI.DrawTexture(_bgRect, background);
	
	  if (!Network.isClient && !Network.isServer)
  	{
		      if (GUI.Button(_startBtnRect, "Start Server"))
			          StartServer();
		
    		  if (GUI.Button(_joinBtnRect, "Refresh List"))
      			    MasterServer.RequestHostList(TypeName);
		
		    if (_hostData != null)
    		{
      			for (int i = 0, l = _hostData.Length; i < l; i++)
      			{
				        _cacheRect.x = 15;
        				_cacheRect.y = Screen.height / 2 + (55 * i);
				
       				if (GUI.Button(_cacheRect,_hostData[i].gameName))
					         JoinServer(_hostData[i]);
      			}
		    }
  	}
}

Il faut commencer par déclarer de nouvelles variables pour le placement des boutons. Ensuite nous ajoutons sur le côté droit, deux boutons. Le premier va lancer la création du serveur via la méthode StartServer que nous écrirons plus bas. En cliquant sur ce bouton, le joueur devient le créateur de partie et est donc considéré par Unity comme le serveur.  Le deuxième bouton va demander au MasterServer la liste des parties en cours pour notre jeu. Vous remarquerez que l'on spécifie le TypeName (l'identifiant unique de votre jeu sur le serveur).

Si le tableau _hostData contient des choses (cela veut dire qu'il y a des parties en cours) alors on les affiches, sous forme de bouton. Si l'utilisateur clic dessus alors la méthode JoinServer est appelée. Nous détaillerons JoinServer plus bas. Commençons par implémenter StartServer.

private void StartServer()
{
	  if (!Network.isClient && !Network.isServer)
	  {
		    Network.InitializeServer(4, 2500, !Network.HavePublicAddress());
		    MasterServer.RegisterHost(TypeName, GameName);
	  }
}

void OnServerInitialized() 
{ 
    Application.LoadLevel(levelToLoad); 
}

Un script héritant de MonoBehaviour a pleins de fonctions spécifiques, nous connaissons bien Start, Update, LateUpdate, etc... il y a aussi des méthodes pour le réseau et je vais vous en présenter deux : OnServerInitialized et OnConnectedToServer. La première est appelée quand un serveur est créé et la deuxième quand un joueur s'est connecté au serveur.

La méthode StartServer va créer une nouvelle partie sur le serveur si le joueur n'est pas déjà un considéré comme un serveur ou un client. Vous noterez que l'on peut savoir à n'importe quel moment si un joueur est un client, un serveur ou aucun des deux via Network.isClient/isServer.

Network.InitializeServer est très simple, elle prend le nombre de joueur maximum en premier paramètre, suivie du port, là vous mettez ce que vous voulez, sauf le port 80 bien sur 😉

MasterServer.RegisterHost va enregistrer votre partie sur le serveur. Notez que l'on spécifie le TypeName qui identifie votre jeu sur le serveur, et le GameName (le nom de la partie). Imaginez simplement que sur le serveur il y ai le code suivant :

Dictionnary> _games;

La clé du dictionnaire est le TypeName de votre jeu, la valeur correspondant à toutes les parties actuellement en cours. Facile non ?

Et enfin la méthode OnServerInitialized sera ainsi appelée quand le serveur aura enregistré la partie de jeu. Là le joueur est identifié par le serveur et connecté, on peut charger le prochain niveau. Notez que cette méthode n'est appelée que pour le joueur considéré serveur et que la méthode OnConnectedToServer ne sera pas appelée pour lui, elle est réservée au clients.

Maintenant que ce passe t-il quand un joueur clic sur le bouton rafraichir ?

private void JoinServer(HostData gameToJoint) 
{ 
    GameToJoin = gameToJoint;
    Application.LoadLevel (levelToLoad); 
}

void OnMasterServerEvent(MasterServerEvent sEvent)
{
	    if (sEvent == MasterServerEvent.HostListReceived)
		        		_hostData = MasterServer.PollHostList();
}

Souvenez vous le bouton faisait appel à la méthode Network.RequestHostList(TypeName) qui lançait un appel au serveur pour lui demander la liste des parties en cours. Quand le serveur répond à ce genre de demandes, la méthode OnMasterServerEvent est appelée et en testant son paramètre on peut savoir de quel type de retour il s'agit. Si la liste des parties à été envoyée alors on la met à jour. Ainsi si le tableau _hostData est non null alors son contenu est affiché dans le menu, il sera possible de cliquer sur la partie pour la rejoindre. Et que ce passe t-il lorsque le joueur clic sur une partie ? La méthode JoinServer est appelée avec le HostData correspondant à la partie, cette valeur est stockée dans la variable statique GameToJoin et le prochain niveau est tout simplement chargé. A ce stade, le joueur n'est pas encore connecté au serveur, il connait juste la partie qu'il veux rejoindre. Patience il va la rejoindre dans la prochaine section.

Nous en avons fini avec la scène Menu, il n'y a pas énormément de code, cependant il y avait beaucoup de choses à dire ! Prenez le temps de tout relire correctement au besoin et préparez vous pour la suite, c'est nettement plus simple ! Le code final est disponible à cette adresse.

3. Création du monde

Vous pouvez désormais ouvrir la scène GameLevel et la designer un peu (oui c'est votre moment de pause, faites vous plaisir, ajoutez donc un beau terrain, avec de belles textures, amusez vous :P). Pour ma part j'ai réalisé ça :

La map pour ce niveau
La map pour ce niveau

Ce n'est pas ma plus belle réalisation mais ça ira pour ce tutoriel ! Nous allons maintenant créer la structure du niveau avec des GameObject bien spécifiques.

tutonet_hierarchieAjoutez donc votre modèle pour le joueur sur la scène, chez moi c'est un tank ! Il faut qu'on le prépare un peu avant d'en faire un prefab. Créez ensuite un GameObject vide qui servira de conteneur pour les éléments qui composent le niveau. Ajoutez y votre niveau (pour moi c'est un terrain et un plane qui représente l'eau), une lumière et un autre GameObject vide nommé SpawnPoints qui contiendra à son tour 4 autres GameObject avec le script SpawnPoint attaché sur chacun d'eux. Enfin, ajoutez le script LevelManager sur le GameObject nommé Level. Cette structure n'est clairement pas obligatoire rassurez vous mais elle a le mérite de hiérarchiser les choses.

3.1. Préparation du prefab pour le joueur

Ajoutez sur la scène ce qui vous servira de joueur, ça peut être un cube ou un modèles 3D super complexe. Il faudra ajouter les composant BoxCollider (si aucun collider n'est présent), RigidBody, NetworkView et TankController (à créer si ce n'est déjà fait).

tutonet_prefabPlayer

N'oubliez pas d'ajouter des contraintes de rotations sur X et Y sur le RigidBody sous peine d'avoir des comportement étranges.

Le composant NetworkView est ici central, il va réaliser plusieurs tâches pour nous, comme par exemple envoyer votre position au serveur quand elle change. Lorsque votre position est envoyée, le serveur la renvoie à tous les joueurs connectés, ainsi elle est mise à jour chez tout le monde. Ce composant va aussi nous permettre de savoir si l'objet courant est à vous ou pas. Effectivement sur un jeu à 4 joueurs il y aura 4 tanks au maximum, sur chacun d'eux il y a aura un script TankController avec des conditions pour gérer les déplacements. Mais si le "joueur A" appuis sur la touche haut, il ne faut pas que tous les tanks se déplacent vers le haut, juste le sien. Le composant NetworkView comporte une propriété forte utile, isMine, qui indique si cette objet est à vous ou pas. Vous comprenez l'intérêt de ce composant ! Une dernière chose avant de passer au code, je vous ai dit que les propriétés de Transform étaient automatiquement envoyées au serveur, c'est le cas car la propriété Observed du NetworkView est par défaut placée sur Transform. Voici le code de déplacement du joueur :

using UnityEngine;
using System.Collections;

public class TankController : MonoBehaviour 
{	
	  private Transform _transform;
	  private Vector3 _translation;
	  private Vector3 _rotation;
	  private float _velocity = 0.95f;
	  private NetworkView _ntView;
	 
  	public float moveSpeed = 15.0f;
	  public float rotationSpeed = 65.0f;
	
	  void Start ()
	  {
		    _transform = GetComponent();
		    _ntView = GetComponent();
  	}
	
  	void Update () 
	  {
		    if (_ntView.isMine)
		    {
			      _translation.z = Input.GetAxis("Vertical") * moveSpeed * Time.deltaTime;
			
			      if (Input.GetKey(KeyCode.A))
				        _translation.x = -moveSpeed * Time.deltaTime;
			      else if (Input.GetKey(KeyCode.E))
        				_translation.x = moveSpeed * Time.deltaTime;
			
     			_rotation.y = Input.GetAxis("Horizontal") * rotationSpeed * Time.deltaTime;
			
     			_transform.Translate(_translation);
     			_transform.Rotate(_rotation);
			
			     _translation.x *= _velocity;
     			_translation.z *= _velocity;
			     _rotation.y *= _velocity;
		    }
	  }
}

La méthode Update démarre directement avec un test, si la NetworkView actuelle est celle du joueur alors on exécute le code, sinon on ne fait rien. C'est terminé vous n'avez plus rien à faire ! Si en fait il faut créer un prefab de votre joueur, un simple glisser/déposer dans le dossier Prefabs et le tour est joué ! Vous pouvez récupérer le script ici au besoin. Maintenant que nous avons un prefab pour le joueur, il est temps de passer aux points de spawn, puis de terminer par la connexion des clients et nous aurons terminé.

3.2. Placement des points de démarrage

Il faut savoir où est-ce que les joueurs vont démarrer, on ne peux pas les faire apparaître à des endroits aléatoires sur la carte. Les GameObject avec un script SpawnPoint on un petit truc sympa, en plus d'avoir une propriété Transform, qui sera utilisée pour définir la position de spawn, il affichent en mode éditeur un cube de couleur qui permet de les visualiser facilement. Placer ces 4 points là où vous voulez que les joueurs puissent commencer.

tutonet_spawnPoints

J'ai encerclé les points de spawn sur ma carte, il y en a 4 ! C'est presque terminé, vous allez bientôt pouvoir jouer.

3.3. Ajouter un joueur sur la scène avec Network.Instanciate

Normalement vous devez déjà avoir placé le script LevelManager sur le GameObject nommé Level, si ce n'est pas le cas vous pouvez le faire maintenant. Ouvrez ce fichier avec votre éditeur préféré (Visual Studio donc...) et commençons d'écrire le code qui va bien.

public class LevelManager : MonoBehaviour 
{
	    public CameraFollow playerCam;
    	public GameObject playerPrefab;
    	public SpawnPoint[] spawnPoints;
	
    	// ...
}

tutoriel_networkConfig2

Nous devons spécifier au script la caméra qui suivra le joueur, le prefab du joueur et enfin les points de démarrage (un tableau de 4 éléments).

void Start () 
{
    	if (Network.isServer)
		        SpawnPlayer();
    	else
		        Network.Connect(NetworkManager.NetworkGUID);
}

Si le joueur est considéré comme un serveur, c'est qu'il a créé une nouvelle partie depuis le menu, donc on peut l'ajouter tout de suite sur la carte. Si c'est un client, il a choisi une partie et la variable statique NetworkGUID que nous avons créée dans le script NetworkManager a comme valeur celle du GUID de la partie. Mais que ce passe t-il quand Network.Connect est exécutée ? Et bien dés que le client sera connecté la méthode OnConnectedToServer sera appelée !

void OnConnectedToServer()
{
	    SpawnPlayer();
}

On se passerait presque de commentaires 😛 mais grossièrement quand le client est connecté, on l'ajoute sur la scène (et pas avant, il faut qu'il soit connecté pour ça). Quand cette fonction est exécutée vous êtes donc sûr que le joueur client est connecté. Le joueur serveur est déjà connecté depuis le menu car il a créé la partie.

Vous pourriez me demander pourquoi est-ce que l'on n'a pas connecté le client depuis le menu... J'attendais cette question. En fait nous aurions très bien pu appeler Network.Connect dans le menu pour le client et ça aurait fonctionné. Là où nous aurions eu un problème, c'est au changement de niveau, la connexion aurait été perdu, c'est pour ça qu'il faut se connecter lorsque le niveau est créé et pas avant. Exception faite pour le créateur de la partie.

private void SpawnPlayer()
{
    	int index = 0;
		
    		if (NetworkManager.GameToJoin != null)
			      index = NetworkManager.GameToJoin.connectedPlayers;
	
    		var player = Network.Instantiate(
        playerPrefab,
        spawnPoints[index].transform.position, 
        spawnPoints[index].transform.rotation, 0) as GameObject;

    		playerCam.transform.position =
       spawnPoints[index].transform.position - new Vector3(0, 0, -10);
    		playerCam.transform.rotation =
       spawnPoints[index].transform.rotation;
		
		    // Mise à jour du script de caméra
		    playerCam.target = player.transform;
		    playerCam.enabled = true;
}

C'est terminé ! Nous avons un tableau de quatre SpawnPoint et nous avons un maximum de quatre joueurs, ça tombe bien car on va définir quel SpawnPoint utiliser avec le nombre de joueurs connectés.

Maintenant lisez bien ce qui va suivre, si vous piquez du nez alors allez donc prendre un bon café (sans sucre or course). Dans une partie en ligne, tous les prefabs que vous allez instancier doivent l'être avec Network.Instanciate et pas Instanciate. C'est très important car par exemple si vous demandez à votre tank de tirer que vous appelez Instanciate à la place de Network.Instanciate, alors seul le joueur qui tire pourra voir les balles ! C'est parfaitement logique, le serveur doit connaitre qui est présent sur la scène.

On définie donc la position du joueur et sa rotation avec la propriété Transform du SpawnPoint. Enfin il faut donner comme cible au script de caméra la propriété Transform du joueur que l'on vient d'ajouter et activer le script bien entendu car il était désactivé (fonction Awake dans CameraFollow.cs). Comme pour les autres fois, le code du script complet est disponible ici.

4. Il est temps de tester !

Le résultat final !

Maintenant vous pouvez faire une build de votre jeu et l'essayer ! Créez par exemple une partie depuis l'éditeur et rejoignez là depuis votre build.  Vous noterez qu'il y a un petit lag lors de la mise à jour des positions, c'est normal et nous allons tenter de le corriger dès le prochain tutoriel. Nous parlerons de la latence du réseau, d'interpolation et de  prédiction.

Conclusion

Vous avez désormais toutes les cartes en main pour créer des jeux multi-joueurs en local avec Unity 4.x. Avec un peu de pratique vous trouverez ça très facile à mettre en place, mais le meilleur reste à venir avec des optimisations nécessaires à une bonne expérience pour les joueurs dans les prochains tutoriels.

Vous pouvez télécharger le projet complet sur github à cette adresse. N'hésitez pas à commenter et à partager vos réalisations !

Un grand merci à Alex Frêne pour la relecture.