Multijugador de alto nivel

Alto nivel VS bajo nivel API

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

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

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.

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.

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

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:

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

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.

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

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

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

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.

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.
    rpc_id(1, "done_preconfiguring", selfPeerID)

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

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.

The network master can be set with the function Node.set_network_master(id, recursive) (recursive is true by default and means the network master is recursively set on all child nodes of the node as well).

Checking that a specific node instance on a peer is the network master for this node for all connected peers is done by calling Node.is_network_master(). This will return true when executed on the server and false on all client peers.

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

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:

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