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 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

Pour en savoir plus sur le TCP/IP, l'UDP et le réseau : https://gafferongames.com/post/udp_vs_tcp/

Gaffer On Games propose de nombreux articles utiles sur le réseautage dans les Jeux (ici), notamment le très complet introduction aux modèles de réseautage dans les jeux.

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 utilise un objet de niveau intermédiaire NetworkedMultiplayerPeer. Cet objet n'est pas destiné à être créé directement, mais est conçu de manière à ce que plusieurs implémentations puissent le fournir.

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.

Cette interface de classe peut abstraire la plupart des types de couches de réseau, de topologies et de bibliothèques. Par défaut, Godot fournit une implémentation basée sur ENet (NetworkedMultiplayerEnet), une basée sur WebRTC (WebRTCMultiplayer), et une basée sur WebSocket (WebSocketMultiplayerPeer), mais ceci pourrait être utilisé pour implémenter des API mobiles (pour le WiFi adhoc, Bluetooth) ou des API réseau spécifiques à un appareil ou une console.

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().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().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().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

Certains jeux acceptent les connexions à tout moment, d'autres pendant la phase de lobby. On peut demander à Godot de ne plus accepter de connexions à tout moment (voir set_refuse_new_network_connections(bool)` et les méthodes associées dans SceneTree). Pour gérer qui se connecte, Godot fournit les signaux suivants dans 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.

Il y a aussi la fonction get_rpc_sender_id dans SceneTree qui peut être utilisée pour vérifier quel pair (ou ID de pair) a envoyé un 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

Vous aurez peut-être déjà remarqué autre chose, l’usage du mot-clé remote sur la fonction register_player :

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

Le mot-clé remote signifie que l’appel rpc() traversera le réseau et sera exécuté à distance.

Le mot-clé remotesync signifie que l’appel rpc() traversera le réseau et sera exécuté à distance, mais qu’il sera aussi exécuté localement (et fera donc un appel de fonction normal).

Les autres seront expliqués plus loin. Notez qu’on peut aussi utiliser la fonction get_rpc_sender_id sur SceneTree pour vérifier quel pair a fait l’appel RPC à 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.
    # The server can call get_tree().get_rpc_sender_id() to find out who said they were done.
    rpc_id(1, "done_preconfiguring")

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():
    var who = get_tree().get_rpc_sender_id()
    # 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():
    # Only the server is allowed to tell a client to unpause
    if 1 == get_tree().get_rpc_sender_id():
        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

Le véritable intérêt de ce modèle est quand on l’utilise avec les mots-clés master/puppet en GDScript, (ou leur équivalent en C# et Visual Script). Tout comme le mot-clé remote, les fonctions peuvent aussi être étiquetées avec ces mots-clés :

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 this player instance for myself as well; could instead have used
    # the remotesync keyword above (in place of puppet) to achieve this.
    stun()

Dans l’exemple ci-dessus, une bombe explose quelque part (sans doute gérée par le maître de ce nœud bombe, par exemple l’hôte). La bombe connaît les corps dans la zone (les nœuds player), donc elle les teste et vérifie qu’ils contiennent une fonction exploded avant de l'appeler.

Rappelez-vous que chaque pair possède un ensemble complet d'instances de nœuds player, une instance pour chaque pair (y compris lui-même et l'hôte). Chaque pair s'est réglé comme maître de l'instance qui lui correspond, et il a fixé un pair différent comme maître pour chacune des autres instances.

Maintenant, pour en revenir à l'appel de la méthode exploded, la bombe sur l'hôte l'a appelée à distance sur tous les corps de la zone qui ont la méthode. Cependant, cette méthode se trouve dans un nœud player et a un mot-clé master.

Le mot-clé master de la méthode exploded dans le nœud player signifie deux choses pour la façon dont cet appel est effectué. Premièrement, du point de vue du pair appelant (l'hôte), le pair appelant ne tentera d'appeler à distance la méthode que sur le pair qu'il a défini comme maître réseau du nœud player en question. Deuxièmement, du point de vue du pair auquel l'hôte envoie l'appel, le pair n'acceptera l'appel que s'il s'est défini comme maître réseau du nœud player avec la méthode appelée (qui a le mot-clé master). Cela fonctionne bien tant que tous les pairs s'accordent sur qui est le maître de quoi.

La configuration ci-dessus signifie que seul le pair qui possède le corps affecté sera responsable de dire à tous les autres pairs que son corps a été assommé, après avoir reçu l'instruction à distance de le faire par la bombe de l'hôte. Le pair propriétaire (toujours dans la méthode exploded) dit donc à tous les autres pairs que son nœud player a été assommé. Le pair fait cela en appelant à distance la méthode stun sur toutes les instances de ce nœud player (sur les autres pairs). Comme la méthode stun a le mot-clé puppet, seuls les pairs qui ne se sont pas définis comme maître réseau du nœud l'appelleront (en d'autres termes, ces pairs sont définis comme marionnettes pour ce nœud parce qu'ils n'en sont pas le maître réseau).

Le résultat de cet appel à stun est de donner au player l'air étourdi sur l'écran de tous les pairs, y compris le pair maître réseau (en raison de l'appel local à stun après rpc("stun")).

Le maître de la bombe (l'hôte) répète les étapes ci-dessus pour chacun des corps dans la zone, de sorte que toutes les instances de n'importe quel player dans la zone de la bombe soient étourdis sur les écrans de tous les pairs.

Veuillez noter qu’on peut aussi envoyer le message stun() uniquement à un joueur donné en utilisant rpc_id(<id>, "exploded", bomb_owner). Ça n’a pas forcément de sens pour le cas d’une zone d’effet comme cette bombe, mais plutôt dans d’autres cas, comme des dégâts à une seule cible.

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

Exportation pour les serveurs dédiés

Une fois que vous avez créé un jeu multijoueur, vous pouvez l'exporter pour le faire tourner sur un serveur dédié sans GPU. Voir Exportation pour les serveurs dédiés pour plus d'informations.

Note

Les exemples de code sur cette page ne sont pas conçus pour fonctionner sur un serveur dédié. Vous devrez les modifier pour que le serveur ne soit pas considéré comme un joueur. Vous devrez également modifier le mécanisme de démarrage du jeu afin que le premier joueur qui se joint à la partie puisse démarrer la partie.

Note

L'exemple bomberman est ici largement utilisé à des fins d'illustration, et ne fait rien du côté de l'hôte pour traiter le cas où un pair utilise un client personnalisé pour tricher en refusant par exemple de s'assommer lui-même. Dans la version actuelle, une telle triche est parfaitement possible car chaque client est le maître réseau de son propre player, et le maître réseau d'un player est celui qui décide d'appliquer la méthode (stun) à tous les autres pairs et à lui-même.