Multijoueur de haut niveau

API de haut niveau contre API de bas niveau

Ce qui suit explique les différences entre les API réseaux de haut et bas niveau dans Godot ainsi que certains principes fondamentaux. Si vous voulez plonger dans le vif du sujet et ajouter une prise en charge réseau à vos premiers nœuds, passez directement à Initialisation du réseau. Mais assurez-vous de lire le reste plus tard !

Godot a toujours soutenu les réseaux standard de bas niveau via UDP, TCP et certains protocoles de niveau supérieur tels que SSL et HTTP. Ces protocoles sont flexibles et peuvent être utilisés pour presque tout. Cependant, leur utilisation pour synchroniser manuellement l’état du jeu peut représenter une grande quantité de travail. Parfois, ce travail ne peut pas être évité ou en vaut la peine, par exemple lorsqu’on travaille avec une implémentation de serveur personnalisé sur le backend. Mais dans la plupart des cas, il est intéressant de considérer l’API de réseau de haut niveau de Godot, qui sacrifie une partie du contrôle fin du réseau de bas niveau pour une plus grande facilité d’utilisation.

Cela est dû aux limites inhérentes aux protocoles de bas niveau :

  • Le TCP garantit que les paquets arriveront toujours de manière fiable et en ordre, mais la latence est généralement plus élevée en raison de la correction des erreurs. C’est aussi un protocole assez complexe parce qu’il comprend ce qu’est une « connexion » et optimise pour des objectifs qui souvent ne conviennent pas à des applications comme les jeux multijoueurs. Les paquets sont mis en mémoire tampon pour être envoyés par lots plus importants, ce qui permet de réduire les coûts par paquet en échange d’augmenter la latence. Cela peut être utile pour des choses comme le HTTP, mais généralement pas pour les jeux. Certains de ces éléments peuvent être configurés et désactivés (par exemple en désactivant « l’algorithme de Nagle » pour la connexion TCP).
  • L’UDP est un protocole plus simple, qui n’envoie que des paquets (et n’a aucune notion de « connexion »). Aucune correction d’erreur permet d’obtenir un résultat rapide (faible latence), mais les paquets peuvent être perdus en cours de route ou reçus dans le mauvais ordre. De plus, la MTU (taille maximale des paquets) pour l’UDP est généralement faible (quelques centaines d’octets seulement), de sorte que la transmission de paquets plus volumineux implique de les diviser, de les réorganiser et de réessayer si une partie échoue.

En général, le TCP peut être considéré comme fiable, ordonné et lent ; l’UDP comme peu fiable, non ordonné et rapide. En raison de la grande différence de performances, il est souvent judicieux de reconstruire les parties de TCP souhaitées pour les jeux (fiabilité en option et ordre des paquets), tout en évitant les parties non souhaitées (fonctions de contrôle de la congestion/du trafic, algorithme de Nagle, etc.) De ce fait, la plupart des moteurs de jeu sont livrés avec une telle implémentation, et Godot ne fait pas exception.

En résumé, vous pouvez utiliser l’API réseau de bas niveau pour un contrôle maximal et implémenter le tout au-dessus des protocoles réseau nus ou utiliser l’API de haut niveau basée sur :ref:`SceneTree <class_SceneTree>`qui fait la plupart du gros du travail derrière la scène d’une manière généralement optimisée.

Note

La plupart des plateformes prises en charge par Godot offrent toutes ou la plupart des fonctionnalités de réseau de haut et de bas niveau mentionnées. Cependant, comme la mise en réseau dépend toujours largement du matériel et du système d’exploitation, certaines fonctionnalités peuvent changer ou ne pas être disponibles sur certaines plateformes cibles. Plus particulièrement, la plate-forme HTML5 n’offre actuellement que la prise en charge de WebSocket et ne dispose pas de certaines des fonctionnalités de haut niveau, ni d’un accès brut aux protocoles de bas niveau comme TCP et UDP.

Note

More about TCP/IP, UDP, and networking: https://web.archive.org/web/20190406162102/https://gafferongames.com/post/udp_vs_tcp/

Gaffer On Games has a lot of useful articles about networking in Games (here), including the comprehensive introduction to networking models in games.

Si vous souhaitez utiliser une bibliothèque réseau de bas niveau de votre choix au lieu du réseau intégré de Godot, voir ici un exemple : https://github.com/PerduGames/gdnet3

Avertissement

Ajouter l’utilisation du réseau dans votre jeu implique une certaine responsabilité. Elle peut rendre votre applicationvulnérable si elle est mal faite et peut conduire à de la triche) ou à des exploitation de faille. Cela peut même permettre à un attaquant de compromettre les machines sur lesquelles votre application tourne et d’utiliser vos serveurs pour envoyer du spam, d’attaquer d’autres personnes ou de voler les données de vos utilisateurs s’ils jouent à votre jeu.

C’est toujours le cas lorsqu’il s’agit de mise en réseau et que cela n’a rien à voir avec Godot. Vous pouvez bien sûr faire des expériences, mais lorsque vous lancez une application en réseau, prenez toujours en compte les éventuels problèmes de sécurité.

Abstraction de niveau moyen

Avant d’expliquer comment nous aimerions synchroniser un jeu sur le réseau, il peut être utile de comprendre comment fonctionne l’API de synchronisation du réseau de base.

Godot uses a mid-level object NetworkedMultiplayerPeer. This object is not meant to be created directly, but is designed so that several implementations can provide it.

Cet objet s’étend de PacketPeer, il hérite donc de toutes les méthodes utiles pour sérialiser, envoyer et recevoir des données. En plus de cela, il ajoute des méthodes pour définir un pair, un mode de transfert, etc. Il comprend également des signaux qui vous permettront de savoir quand vos pairs se connectent ou se déconnectent.

This class interface can abstract most types of network layers, topologies and libraries. By default, Godot provides an implementation based on ENet (NetworkedMultiplayerEnet), one based on WebRTC (WebRTCMultiplayer), and one based on WebSocket (WebSocketMultiplayerPeer), but this could be used to implement mobile APIs (for adhoc WiFi, Bluetooth) or custom device/console-specific networking APIs.

Dans la plupart des cas, il est déconseillé d’utiliser directement cet objet, car Godot fournit des possibilités de mise en réseau de niveau encore plus élevé. Il est cependant mis à disposition au cas où un jeu aurait des besoins spécifiques pour une API de niveau inférieur.

Initialisation du réseau

L’objet qui contrôle le réseau dans Godot est le même qui contrôle tout ce qui concerne l’arbre de scène : SceneTree.

Pour initialiser le réseau de haut niveau, il faut fournir au SceneTree un objet NetworkedMultiplayerPeer.

Pour créer cet objet, il doit d’abord être initialisé en tant que serveur ou client.

Initialisation en tant que serveur, écoute sur le port donné, avec un nombre maximum donné de pairs :

var peer = NetworkedMultiplayerENet.new()
peer.create_server(SERVER_PORT, MAX_PLAYERS)
get_tree().set_network_peer(peer)

S’initialiser en tant que client, se connecter à une IP et un port donnés :

var peer = NetworkedMultiplayerENet.new()
peer.create_client(SERVER_IP, SERVER_PORT)
get_tree().set_network_peer(peer)

Obtenez le pair de réseau précédemment défini :

get_tree().get_network_peer()

Vérifier si l’arbre est initialisé en tant que serveur ou client :

get_tree().is_network_server()

Mettre fin à la fonction réseau :

get_tree().set_network_peer(null)

(Bien qu’il puisse être judicieux d’envoyer d’abord un message pour faire savoir aux autres pairs que vous partez, au lieu de laisser la connexion se fermer ou s’interrompre, selon votre jeu.)

Gestion des connexions

Some games accept connections at any time, others during the lobby phase. Godot can be requested to no longer accept connections at any point (see set_refuse_new_network_connections(bool) and related methods on SceneTree). To manage who connects, Godot provides the following signals in SceneTree:

Serveur et clients :

  • network_peer_connected(int id)
  • network_peer_disconnected(int id)

Les signaux ci-dessus sont appelés pour chaque pair connecté au serveur (y compris sur le serveur), quand un nouveau pair se connecte ou se déconnecte. Les clients se connectent avec un identifiant unique (ID) supérieur à 1, et l’identifiant 1 est toujours le serveur. Tous les ID inférieurs à 1 doivent être traités comme invalides. On peut récupérer l’ID pour le système local avec SceneTree.get_network_unique_id(). Ces ID sont surtout utiles pour la gestion des salons et devraient généralement être stockés car ils identifient les pairs connectés et donc les joueurs. On peut aussi utiliser les identifiants pour n’envoyer des messages qu’à certains pairs.

Clients :

  • connected_to_server
  • connection_failed
  • server_disconnected

Encore une fois, toutes ces fonctions sont surtout utiles pour la gestion des salons, ou pour ajouter ou retirer des joueurs à la volée. Dans ces cas-là, le serveur doit clairement marcher comme un serveur, et il faut faire des manipulations à la main, comme envoyer aux joueurs qui se connectent des informations sur les joueurs déjà connectés (par exemple leurs noms, statistiques, etc.).

Les salons peuvent être implémentés comme on le veut, mais la manière la plus courante est d’utiliser un nœud avec le même nom dans les scènes chez tous les pairs. En général, un nœud ou singleton auto-chargés convient très bien pour ça, pour avoir toujours accès, par exemple, à « /root/salon ».

RPC

Pour communiquer entre pairs, le plus simple est d’utiliser des RPC (remote procedure calls, appels de procédures distantes). Ils sont implémentés sous la forme d’un ensemble de fonctions dans Node :

  • rpc("function_name", <optional_args>)
  • rpc_id(<peer_id>,"function_name", <optional_args>)
  • rpc_unreliable("function_name", <optional_args>)
  • rpc_unreliable_id(<peer_id>, "function_name", <optional_args>)

Il est aussi possible de synchroniser des variables membres :

  • rset("variable", value)
  • rset_id(<peer_id>, "variable", value)
  • rset_unreliable("variable", value)
  • rset_unreliable_id(<peer_id>, "variable", value)

Il y a deux manières d’appeler les fonctions :

  • Fiable : l’appel de fonction arrivera quoi qu’il arrive, mais prendra plus longtemps car il devra être transmis à nouveau en cas d’échec.
  • Non-fiable : si l’appel de fonction n’arrive pas, il ne sera pas retransmis ; mais s’il arrive, ce sera rapidement.

Dans la plupart des cas, il vaut mieux choisir fiable. Non-fiable est surtout utile pour synchroniser des positions d’objets : la synchronisation doit se faire constamment, et si un paquet est perdu, ce n’est pas très grave car un autre arrivera tôt ou tard. Le premier ne serait sans doute plus à jour même s’il était renvoyé de manière fiable, car l’objet aurait bougé pendant ce temps.

There is also the get_rpc_sender_id function in SceneTree, which can be used to check which peer (or peer ID) sent an RPC.

Retour au lobby

Revenons au salon. Imaginons que chaque joueur qui se connecte au serveur prévienne tout le monde.

# Typical lobby implementation; imagine this being in /root/lobby.

extends Node

# Connect all functions

func _ready():
    get_tree().connect("network_peer_connected", self, "_player_connected")
    get_tree().connect("network_peer_disconnected", self, "_player_disconnected")
    get_tree().connect("connected_to_server", self, "_connected_ok")
    get_tree().connect("connection_failed", self, "_connected_fail")
    get_tree().connect("server_disconnected", self, "_server_disconnected")

# Player info, associate ID to data
var player_info = {}
# Info we send to other players
var my_info = { name = "Johnson Magenta", favorite_color = Color8(255, 0, 255) }

func _player_connected(id):
    # Called on both clients and server when a peer connects. Send my info to it.
    rpc_id(id, "register_player", my_info)

func _player_disconnected(id):
    player_info.erase(id) # Erase player from info.

func _connected_ok():
    pass # Only called on clients, not server. Will go unused; not useful here.

func _server_disconnected():
    pass # Server kicked us; show error and abort.

func _connected_fail():
    pass # Could not even connect to server; abort.

remote func register_player(info):
    # Get the id of the RPC sender.
    var id = get_tree().get_rpc_sender_id()
    # Store the info
    player_info[id] = info

    # Call function to update lobby UI here

You might have already noticed something different, which is the usage of the remote keyword on the register_player function:

remote func register_player(info):

Ce mot-clé a deux utilisations principales. La première est de faire savoir à Godot que la fonction peut être appelée depuis RPC. Si aucun mot-clé n’est ajouté, Godot bloque par sécurité toutes les tentatives d’appeler des fonctions. Cela facilite beaucoup la sécurité (pour qu’un client ne puisse pas appeler une fonction qui supprime un fichier sur le système d’un autre client).

La seconde utilisation est de spécifier comment la fonction sera appelée par RPC. Il y a quatre mots-clés différents :

  • remote
  • remotesync
  • master
  • puppet

The remote keyword means that the rpc() call will go via network and execute remotely.

The remotesync keyword means that the rpc() call will go via network and execute remotely, but will also execute locally (do a normal function call).

The others will be explained further down. Note that you could also use the get_rpc_sender_id function on SceneTree to check which peer actually made the RPC call to register_player.

Avec tout ça, la gestion de salons est plus ou moins expliquée. Une fois que le jeu tournera, il faudra sûrement ajouter de la sécurité en plus pour s’assurer que les clients ne fassent rien de bizarre (simplement valider les infos qu’ils envoient de temps en temps, ou avant que la partie ne commence). Pour faire simple et parce que chaque jeu partagera des informations différentes, nous ne le montrerons pas ici.

Démarrage du jeu

Une fois que les joueurs sont rassemblés dans le salon, le serveur va probablement lancer la partie. Ça n’a rien de spécial en soi, mais nous allons expliquer quelques petites astuces qu’on peut faire à ce stade pour se simplifier la vie.

Scènes joueur

Dans la plupart des jeux, chaque joueur a sans doute sa propre scène. Il faut se rappeler que c’est un jeu multi-joueurs, donc chez chaque pair il faut instancier une scène pour chaque joueur connecté. Pour un jeu à quatre joueurs, chaque pair doit instancier quatre nœuds de joueurs.

Alors comment appeler ces nœuds ? Dans Godot, les nœuds doivent avoir un nom unique. Il faut aussi que les joueurs puissent savoir assez facilement quel nœud représente chaque identifiant de joueur.

La solution est simplement de donner l’identifiant réseau comme nom au nœud racine des scènes de joueur instanciées. Ainsi, les noms seront les mêmes chez chaque pair et le RPC marchera au poil ! Voici un exemple :

remote func pre_configure_game():
    var selfPeerID = get_tree().get_network_unique_id()

    # Load world
    var world = load(which_level).instance()
    get_node("/root").add_child(world)

    # Load my player
    var my_player = preload("res://player.tscn").instance()
    my_player.set_name(str(selfPeerID))
    my_player.set_network_master(selfPeerID) # Will be explained later
    get_node("/root/world/players").add_child(my_player)

    # Load other players
    for p in player_info:
        var player = preload("res://player.tscn").instance()
        player.set_name(str(p))
        player.set_network_master(p) # Will be explained later
        get_node("/root/world/players").add_child(player)

    # Tell server (remember, server is always ID=1) that this peer is done pre-configuring.
    rpc_id(1, "done_preconfiguring", selfPeerID)

Note

Selon le moment où on exécute pre_configure_game(), il peut falloir différer les appels à add_child() en utilisant call_deferred(), parce que l’arbre des scènes est verrouillé pendant que la scène est crée (p. ex. quand _ready() est appelée).

Synchroniser le démarrage de la partie

Chaque pair peut prendre un temps différent pour configurer les joueurs, à cause de latences, de matériel différent, ou d’autres raisons. Pour s’assurer que la partie commence quand tout le monde est prêt, il peut être utile de mettre le jeu en pause jusqu’à ce que tous les joueurs soient prêts :

remote func pre_configure_game():
    get_tree().set_pause(true) # Pre-pause
    # The rest is the same as in the code in the previous section (look above)

Quand le serveur reçoit le signal de tous les pairs, il peut leur dire de commencer, par exemple :

var players_done = []
remote func done_preconfiguring(who):
    # Here are some checks you can do, for example
    assert(get_tree().is_network_server())
    assert(who in player_info) # Exists
    assert(not who in players_done) # Was not added yet

    players_done.append(who)

    if players_done.size() == player_info.size():
        rpc("post_configure_game")

remote func post_configure_game():
    get_tree().set_pause(false)
    # Game starts now!

Synchronisation du jeu

Dans la plupart des jeux, le but du réseau multi-joueurs et que le jeu soit synchronisé entre tous les pairs qui jouent. En plus de fournir un RPC et l’implémentation d’un groupe de variables membres distantes, Godot ajoute le concept de maîtres réseau.

Maître réseau

Le maître réseau d’un nœud est le pair qui a l’autorité suprême sur lui.

Quand il n’est pas défini explicitement, le maître réseau est hérité du nœud parent, qui sera toujours le serveur (ID 1) s’il n’a pas changé. Le serveur a donc par défaut autorité sur tous les nœuds.

Le maître réseau peut être défini avec la fonction Node.set_network_master(id, recursive) (recursive est true par défaut et signifie que le maître réseau est aussi défini récursivement sur tous les nœuds enfants du nœud).

Pour vérifier qu’une instance donnée d’un nœud chez un pair est le maître réseau de ce nœud chez tous les pairs connectés, on appelle Node.is_network_master(). Ça retourne true quand on l’exécute sur le serveur et false sur tous les clients pairs.

Si vous tendiez l’oreille à l’exemple précédent, vous avez peut-être remarqué que chaque pair était configuré pour avoir l’autorité de maître réseau pour son propre joueur (Nœud) au lieu du serveur :

[...]
# Load my player
var my_player = preload("res://player.tscn").instance()
my_player.set_name(str(selfPeerID))
my_player.set_network_master(selfPeerID) # The player belongs to this peer; it has the authority.
get_node("/root/world/players").add_child(my_player)

# Load other players
for p in player_info:
    var player = preload("res://player.tscn").instance()
    player.set_name(str(p))
    player.set_network_master(p) # Each other connected peer has authority over their own player.
    get_node("/root/world/players").add_child(player)
[...]

À chaque fois que ce bout de code est exécuté sur chaque pair, ce pair se rend maître sur le nœud qu’il contrôle, et tous les autres nœuds restent des pantins, le serveur étant leur maître réseau.

Pour clarifier par l’exemple, voici à quoi ça ressemble dans la démo bomber. :

../../_images/nmms.png

Mots-clés Master et puppet

The real advantage of this model is when used with the master/puppet keywords in GDScript (or their equivalent in C# and Visual Script). Similarly to the remote keyword, functions can also be tagged with them:

Exemple de code pour une bombe :

for p in bodies_in_area:
    if p.has_method("exploded"):
        p.rpc("exploded", bomb_owner)

Exemple de code pour un joueur :

puppet func stun():
    stunned = true

master func exploded(by_who):
    if stunned:
        return # Already stunned

    rpc("stun")
    stun() # Stun myself, could have used remotesync keyword too.

In the above example, a bomb explodes somewhere (likely managed by whoever is master). The bomb knows the bodies in the area, so it checks them and checks that they contain an exploded function.

If they do, the bomb calls exploded on it. However, the exploded method in the player has a master keyword. This means that only the player who is master for that instance will actually get the function.

This instance, then, calls the stun method in the same instances of that same player (but in different peers), and only those which are set as puppet, making the player look stunned in all the peers (as well as the current, master one).

Note that you could also send the stun() message only to a specific player by using rpc_id(<id>, "exploded", bomb_owner). This may not make much sense for an area-of-effect case like the bomb, but in other cases, like single target damage.

rpc_id(TARGET_PEER_ID, "stun") # Only stun the target peer