Multijugador de alto nivel

API de alto nivel vs bajo nivel

A continuación se explican las diferencias de redes de alto y bajo nivel en Godot, así como algunos aspectos fundamentales. Si deseas saltar de cabeza y agregar redes a tus primeros nodos, salta a Inicializando la red a continuación. ¡Pero asegúrate de leer el resto más tarde!

Godot siempre soportó conexiones de red estándar de bajo nivel a través de UDP, TCP y algunos protocolos de alto nivel como SSL y HTTP. Estos protocolos son flexibles y se pueden utilizar para casi cualquier cosa. Sin embargo, usarlos para sincronizar el estado del juego manualmente puede ser una gran cantidad de trabajo. A veces ese trabajo no se puede evitar o vale la pena, por ejemplo cuando se trabaja con una implementación de servidor personalizada en el backend. Pero en la mayoría de los casos vale la pena considerar la API de red de alto nivel de Godot, que sacrifica algo del control detallado de la red de bajo nivel para una mayor facilidad de uso.

Esto se debe a las limitaciones inherentes de los protocolos de bajo nivel:

  • TCP asegura que los paquetes siempre llegarán de manera confiable y en orden, pero la latencia generalmente es más alta debido a la corrección de errores. También es un protocolo bastante complejo porque entiende lo que es una "conexión" y se optimiza para los objetivos que a menudo no se adaptan a aplicaciones como los juegos multijugador. Los paquetes se almacenan en el búfer para enviarlos en lotes más grandes, intercambiando menos sobrecarga por paquete para una mayor latencia. Esto puede ser útil para cosas como HTTP, pero generalmente no para juegos. Parte de esto se puede configurar y desactivar (por ejemplo, desactivando el "algoritmo de Nagle" para la conexión TCP).
  • UDP es un protocolo más simple que solo envía paquetes (y no tiene ningún concepto de "conexión"). No poseer corrección de errores lo hace bastante rápido (baja latencia), pero los paquetes pueden perderse en el camino o ser recibidos en el orden incorrecto. Además de eso, el MTU (tamaño máximo de paquete) para UDP es generalmente baja (solo unos pocos cientos de bytes), por lo que la transmisión de paquetes más grandes significa dividirlos, reorganizarlos y volver a intentar si una pieza falla.

En general, se puede pensar que TCP es confiable, ordenado y lento; UDP como no confiable, no ordenado y rápido. Debido a la gran diferencia en el rendimiento, a menudo tiene sentido volver a construir las partes de TCP deseadas para los juegos (confiabilidad opcional y orden de paquetes) mientras se evitan las partes no deseadas (funciones de congestión/control de tráfico, algoritmo de Nagle, etc.). Debido a esto, la mayoría de los motores de juegos vienen con una implementación así, y Godot no es una excepción.

En resumen, puedes usar la API de red de bajo nivel para obtener el máximo control e implementar todo encima de los protocolos de red puros o usar la API de alto nivel basada en SceneTree que hace la mayor parte del trabajo pesado detrás de las escenas de una manera generalmente optimizada.

Nota

La mayoría de las plataformas compatibles con Godot ofrecen todas o la mayoría de las funciones de red de alto y bajo nivel mencionadas. Sin embargo, como las redes siempre dependen en gran medida del hardware y del sistema operativo, algunas funciones pueden cambiar o no estar disponibles en algunas plataformas de destino. En particular, la plataforma HTML5 actualmente solo ofrece compatibilidad con WebSocket y carece de algunas de las características de mayor nivel, así como del acceso sin formato a protocolos de bajo nivel como TCP y UDP.

Nota

Más sobre TCP/IP, UDP y redes: https://gafferongames.com/post/udp_vs_tcp/

Gaffer On Games tiene muchos artículos útiles (en inglés) sobre la creación de redes en Juegos (aquí), incluida la exhaustiva introduction to networking models in games (introducción a modelos de redes en juegos).

Si deseas utilizar tu biblioteca de redes de bajo nivel preferida en lugar de la red integrada de Godot, consulta aquí un ejemplo: https://github.com/PerduGames/gdnet3

Advertencia

Agregar red a tu juego viene con algo de responsabilidad. Puede hacer que tu aplicación sea vulnerable si se hace mal y puede generar trampas o exploits. Incluso puede permitir que un atacante comprometa las máquinas en las que se ejecuta tu aplicación y use tus servidores para enviar correo no deseado, ataque a otros o robe los datos de tus usuarios si juegan tu juego.

Este es siempre el caso cuando se trata de redes y no tiene nada que ver con Godot. Por supuesto, puedes experimentar, pero cuando liberes una aplicación en red, siempre ocúpate de cualquier posible problema de seguridad.

Abstracción de nivel medio

Antes de entrar en cómo nos gustaría sincronizar un juego en la red, puede ser útil entender cómo funciona la base de la API de red para la sincronización.

Godot usa un objeto de nivel medio NetworkedMultiplayerPeer. Este objeto no esta hecho para crearse directamente, sino que está diseñado para que varias implementaciones C++ puedan proveerlo.

Este objeto se extiende de PacketPeer, por lo que hereda todos los métodos útiles para serializar, enviar y recibir datos. Encima de eso, agrega métodos para configurar un peer, modo de transferencia, etc. También incluye señales que le permitirán saber cuándo se conectan o desconectan los peers.

Esta interfaz de clase puede abstraer la mayoría de los tipos de capas de red, topologías y bibliotecas. Por defecto, Godot proporciona una implementación basada en ENet (NetworkedMultiplayerEnet), una basada en WebRTC (WebRTCMultiplayer) y otra basada en WebSocket (WebSocketMultiplayerPeer), pero esto podría usarse para implementar APIs móviles (para WiFi ad hoc, Bluetooth) o APIs personalizadas de red específicas del dispositivo/consola.

Para la mayoría de los casos comunes, se desaconseja usar este objeto directamente, ya que Godot proporciona funciones de red de nivel aún más alto. Sin embargo, está disponible en caso de que un juego tenga necesidades específicas de un API de nivel inferior.

Inicializando la red

El objeto que controla las redes en Godot es el mismo que controla todo lo relacionado con el árbol: SceneTree.

Para inicializar redes de alto nivel, el SceneTree debe proporcionar un objeto NetworkedMultiplayerPeer.

Para crear ese objeto, primero tiene que ser inicializado como un servidor o cliente.

Inicializando como un servidor, escuchando en el puerto dado, con un número máximo de pares dado:

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

Inicializando como un cliente, conectándose a una IP y un puerto dados:

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

Get the previously set network peer:

get_tree().get_network_peer()

Comprobando si el árbol se inicializó como servidor o cliente:

get_tree().is_network_server()

Terminar la función de red:

get_tree().network_peer = null

(Aunque puede tener sentido enviar un mensaje primero para que los otros compañeros sepan que te vas a ir en lugar de dejar que la conexión se cierre o se agote el tiempo de espera, dependiendo de tu juego.)

Administrar conexiones

Algunos juegos aceptan conexiones en cualquier momento, otros durante la fase de lobby(vestíbulo). Se puede solicitar a Godot que ya no acepte conexiones en ningún punto (mira set_refuse_new_network_connections(bool) y métodos relacionados en SceneTree). Para administrar quién se conecta, Godot proporciona las siguientes señales en SceneTree:

Servidor y Clientes:

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

Las señales anteriores se invocan en cada peer conectado al servidor (incluso en el servidor) cuando un nuevo peer se conecta o desconecta. Los clientes se conectarán con una ID única mayor que 1, mientras que la ID de red peer 1 es siempre el servidor. Cualquier cosa por debajo de 1 debe manejarse como inválida. Puedes recuperar la ID para el sistema local a través de SceneTree.get_network_unique_id(). Estas IDs serán útiles sobre todo para la gestión del lobby y, en general, se deben almacenar ya que identifican a los peers conectados y, por tanto, a los jugadores. También puedes usar las IDs para enviar mensajes solo a ciertos peers.

Clientes:

  • connected_to_server
  • connection_failed
  • server_disconnected

Una vez más, todas estas funciones son principalmente útiles para la gestión del lobby o para agregar/quitar jugadores en el momento. Para estas tareas, el servidor claramente tiene que funcionar como servidor y tu debes realizar tareas manualmente, como enviar un jugador recién conectado o información sobre otros jugadores que ya están conectados (por ejemplo, sus nombres, estadísticas, etc.).

Los lobbys pueden ser implementados de la forma que desees, pero la manera más común es usar un nodo con el mismo nombre a través de escenas en todos los peers. Generalmente, un nodo/singleton autocargado es ideal para esto, para tener siempre acceso a, por ejemplo, "/root/lobby".

RPC

Para comunicarse entre peers, la forma más fácil es usar RPC (llamadas de procedimiento remoto). Esto se implementa como un conjunto de funciones en 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>)

Synchronizing member variables is also possible:

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

Las funciones se pueden llamar de dos maneras:

  • Confiable: la llamada a la función llegará sin importar qué, pero puede llevar más tiempo porque se retransmitirá en caso de falla.
  • No Confiable: si la llamada de función no llega, no se retransmitirá, pero si llega, lo hará rápidamente.

En la mayoría de los casos, confiable es deseado. No confiable es útil principalmente para sincronizar posiciones de objetos (la sincronización debe ocurrir constantemente, y si se pierde un paquete, no es tan malo porque finalmente llegará uno nuevo y es probable que esté desactualizado porque, mientras tanto el objeto se movió aún más, incluso si fue reenviado de manera confiable).

También está la función get_rpc_sender_id en SceneTree que se puede usar para verificar qué peer (o ID de peer) envió una llamada RPC.

De vuelta al lobby

Volvamos al lobby. Imagina que cada jugador que se conecta al servidor se lo cuenta a todos.

# 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

Ya habrás notado algo diferente, que es el uso de la palabra clave remote en la función register_player:

remote func register_player(info):

Esta palabra clave tiene dos usos principales. El primero es dejar que Godot sepa que esta función se puede llamar desde RPC. Si no se agregan palabras clave, Godot bloqueará cualquier intento de invocar funciones por razones de seguridad. Esto hace que la seguridad funcione mucho más fácilmente (por lo que un cliente no puede llamar a una función para eliminar un archivo en el sistema de otro cliente).

El segundo uso es especificar cómo se llamará la función a través de RPC. Hay cuatro palabras clave diferentes:

  • remote
  • remotesync
  • master
  • puppet

La palabra clave remote significa que la llamada rpc() irá a través de la red y se ejecutará de forma remota.

La palabra clave remotesync significa que la llamada``rpc()`` irá a través de la red y se ejecutará de forma remota, pero también se ejecutará localmente (realiza una llamada normal a una función).

Los otros serán explicados más abajo. Ten en cuenta que también se puede usar la función get_rpc_sender_id en `` SceneTree`` para verificar qué peer realmente hizo la llamada RPC a register_player.

Con esto, la gestión del lobby debería estar más o menos explicada. Una vez que tengas listo tu juego, lo más probable es que quieras agregar un poco de seguridad adicional para asegurarse de que los clientes no hagan nada raro (solo valida la información que envían de vez en cuando, o antes de que comience el juego). Por razones de simplicidad y porque cada juego compartirá información diferente, esto no se muestra aquí.

Comenzando el juego

Una vez que se hayan reunido suficientes jugadores en el lobby, el servidor probablemente debería comenzar el juego. Esto no es nada especial en sí mismo, pero explicaremos algunos trucos agradables que se pueden hacer en este punto para hacer tu vida mucho más fácil.

Escenas del jugador

En la mayoría de los juegos, cada jugador probablemente tendrá su propia escena. Recuerda que este es un juego multijugador, por lo que en cada peer debes incluir una escena para cada jugador conectado a ella. Para un juego de 4 jugadores, cada peer necesita instaurar 4 nodos de jugador.

Entonces, ¿cómo nombrar esos nodos? En Godot, los nodos deben tener un nombre único. También debe ser relativamente fácil para un jugador decir qué nodos representan cada ID de jugador.

La solución es simplemente nombrar el nodo raíz de las escenas del jugador instanciadas como su ID de red. De esta forma, serán iguales en todos los pares y RPC funcionará de maravilla. Aquí hay un ejemplo:

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

Nota

Dependiendo de cuándo ejecutes pre_configure_game(), es posible que necesites cambiar algunas llamadas a add_child() a ser diferidas mediante call_deferred(), ya que SceneTree está bloqueado mientras se está creando la escena (por ejemplo, cuando _ready() se está llamando).

Sincronizando el inicio del juego

Configurar los jugadores puede tomar una cantidad de tiempo diferente en cada peer debido al lag(retraso), hardware diferente u otras razones. Para asegurarte de que el juego realmente comience cuando todos estén listos, pausar el juego hasta que todos los jugadores estén listos puede ser útil:

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)

Cuando el servidor obtiene el OK de todos los peers, puede indicarles que comiencen, como por ejemplo:

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!

Sincronizando el juego

En la mayoría de los juegos, el objetivo de las redes multijugador es que el juego se ejecute sincronizado con todos los peers que lo juegan. Además de proporcionar un RPC y la implementación de un conjunto de variables miembro remotas, Godot añade el concepto de red maestra.

Red maestra

La red maestra de un nodo es el peer que tiene la máxima autoridad sobre él.

Cuando no se establece explícitamente la red maestra es heredada del nodo padre, que si no se cambia siempre será el servidor (ID 1). Por tanto, el servidor tiene autoridad sobre todos los nodos por defecto.

El master de la red se puede configurar con la función Node.set_network_master(id, recursive) (recursive es true de manera predeterminada y significa que el master de la red también se establece recursivamente en todos los nodos hijos del nodo).

Comprobar que una instancia de nodo específica en un peer es el master de red para este nodo, en todos los peers conectados, se hace llamando a Node.is_network_master(). Este retornara true cuando se ejecute en el servidor y false en todos los peers clientes.

Si has prestado atención al ejemplo anterior, es posible que haya notado que el peer local está configurado para tener autoridad de red maestra para su propio (nodo) jugador en lugar del servidor:

[...]
# 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)
[...]

Cada vez que esta pieza de código se ejecuta en cada peer, el peer se hace maestro en el nodo que controla, y todos los otros nodos permanecen como marionetas con el servidor siendo su red maestra.

Para aclarar, aquí hay un ejemplo de cómo se ve esto en el bomber demo:

../../_images/nmms.png

Palabras clave master y puppet

La verdadera ventaja de este modelo es cuando se usa con las palabras clave master/puppet en GDScript (o su equivalente en C# y Visual Script). Similar a la palabra clave remote, las funciones también se pueden etiquetar con ellas:

Ejemplo de código de bomba:

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

Ejemplo de código de jugador:

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

In the above example, a bomb explodes somewhere (likely managed by whoever is the master of this bomb-node, e.g. the host). The bomb knows the bodies (player nodes) in the area, so it checks that they contain an exploded method before calling it.

Recall that each peer has a complete set of instances of player nodes, one instance for each peer (including itself and the host). Each peer has set itself as the master of the instance corresponding to itself, and it has set a different peer as the master for each of the other instances.

Now, going back to the call to the exploded method, the bomb on the host has called it remotely on all bodies in the area that have the method. However, this method is in a player node and has a master keyword.

The master keyword on the exploded method in the player node means two things for how this call is made. Firstly, from the perspective of the calling peer (the host), the calling peer will only attempt to remotely call the method on the peer that it has set as the network master of the player node in question. Secondly, from the perspective of the peer the host is sending the call to, the peer will only accept the call if it set itself as the network master of the player node with the method being called (which has the master keyword). This works well as long as all peers agree on who is the master of what.

The above setup means that only the peer who owns the affected body will be responsible for telling all the other peers that its body was stunned, after being remotely instructed to do so by the host's bomb. The owning peer therefore (still in the exploded method) tells all the other peers that its player node was stunned. The peer does this by remotely calling the stun method on all instances of that player node (on the other peers). Because the stun method has the puppet keyword, only peers who did not set themselves as the network master of the node will call it (in other words, those peers are set as puppets for that node by virtue of not being the network master of it).

The result of this call to stun is to make the player look stunned on the screen of all the peers, including the current network master peer (due to the local call to stun after rpc("stun")).

The master of the bomb (the host) repeats the above steps for each of the bodies in the area, such that all the instances of any player in the bomb area get stunned on the screens of all the peers.

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 might in other cases, like single target damage.

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

Exportando para servidores dedicados

Once you've made a multiplayer game, you may want to export it to run it on a dedicated server with no GPU available. See Exportando para servidores dedicados for more information.

Nota

The code samples on this page aren't designed to run on a dedicated server. You'll have to modify them so the server isn't considered to be a player. You'll also have to modify the game starting mechanism so that the first player who joins can start the game.

Nota

The bomberman example here is largely for illustrational purposes, and does not do anything on the host-side to handle the case where a peer uses a custom client to cheat by for example refusing to to stun itself. In the current implementation such cheating is perfectly possible because each client is the network master of its own player, and the network master of a player is the one which decides whether to call the I-was-stunned method (stun) on all of the other peers and itself.