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 ofrece compatibilidad con WebSocket y con WebRTC pero, 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

Consigue el par de la red previamente establecido:

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

Advertencia

Cuando exportes a Android, asegúrate de habilitar el permiso INTERNET en la configuración de exportación de Android antes de exportar el proyecto o utilizar el despliegue de un solo clic. De lo contrario, la comunicación de red de cualquier tipo será bloqueada por Android.

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

También es posible sincronizar las variables de los miembros:

  • 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: cuando se recibe la llamada a una función, se enviará un acuse de recibo; si no se recibe el acuse de recibo después de cierto tiempo, la llamada a la función se retransmitirá.

  • No confiable: la llamada a la función se envía solo una vez, sin verificar si llegó o no, pero también sin ningún gasto adicional.

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 existe SceneTree.get_rpc_sender_id(), que se puede utilizar para verificar qué par (o ID de par) envió un 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 es una de muchas que permiten que una función sea llamada mediante una llamada a procedimiento remoto (RPC). En total, hay seis de ellas:

  • remote

  • remotesync

  • puppet

  • puppetsync

  • master

  • mastersync

Cada uno de ellos designa quién puede llamar al RPC y opcionalmente sync si el RPC se puede llamar localmente.

Nota

Si no se agregan palabras clave de RPC, Godot bloqueará cualquier intento de llamar a funciones de forma remota. Esto facilita mucho el trabajo de seguridad, ya que un cliente no podrá llamar a una función para eliminar un archivo en el sistema de otro cliente.

La palabra clave remote puede ser llamada por cualquier par, incluido el servidor y todos los clientes. La palabra clave puppet significa que una llamada puede ser realizada desde el maestro de red a cualquier títere de red. La palabra clave master significa que una llamada puede ser realizada desde cualquier títere de red al maestro de red.

Si se incluye sync, la llamada también se puede realizar localmente. Por ejemplo, para permitir que el maestro de red cambie la posición del jugador en todos los pares:

puppetsync func update_position(new_position):
    position = new_position

Truco

También puedes usar SceneTree.get_rpc_sender_id() para tener reglas más avanzadas sobre cómo se puede llamar a un RPC.

Estas palabras clave se explican con más detalle en Sincronización del juego.

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

En el ejemplo anterior, una bomba explota en algún lugar (probablemente administrada por quien sea el maestro de este nodo de bomba, por ejemplo, el anfitrión). La bomba conoce los cuerpos (nodos de jugador) en el área, por lo que verifica que contengan un método explotado antes de llamarlo.

Recordemos que cada par tiene un conjunto completo de instancias de nodos jugadores, una instancia para cada par (incluyéndose a sí mismo y al anfitrión). Cada par se ha establecido como el maestro de la instancia correspondiente a sí mismo, y ha establecido un par diferente como maestro para cada una de las otras instancias.

Ahora, volviendo a la llamada del método exploded, la bomba en el huésped ha llamado remotamente a todos los cuerpos en el área que tienen el método. Sin embargo, este método está en un nodo de jugador y tiene una palabra clave master.

La palabra clave master en el método exploded en el nodo de jugadores significa dos cosas para la forma en que se hace esta llamada. Primero, desde la perspectiva del par que llama (el anfitrión), el par que llama sólo intentará llamar remotamente al método en el par que ha establecido como maestro de la red del nodo jugador en cuestión. En segundo lugar, desde la perspectiva del par al que el host está enviando la llamada, el par sólo aceptará la llamada si se establece como el maestro de la red del nodo jugador con el método que se está llamando (que tiene la palabra clave master). Esto funciona bien siempre y cuando todos los pares estén de acuerdo en quién es el maestro de qué.

La configuración anterior significa que sólo el compañero que posee el cuerpo afectado será responsable de decir a todos los demás compañeros que su cuerpo fue aturdido, después de haber sido instruido remotamente para hacerlo por la bomba del anfitrión. Por lo tanto, el compañero propietario (aún en el método exploded) le dice a todos los demás compañeros que su nodo de jugador fue aturdido. El compañero hace esto llamando remotamente al método stun en todas las instancias de ese nodo jugador (en los otros compañeros). Debido a que el método stun tiene la palabra clave puppet, sólo los compañeros que no se establecieron como el maestro de la red del nodo lo llamarán (en otras palabras, esos compañeros se establecen como puppets para ese nodo en virtud de no ser el maestro de la red del mismo).

El resultado de esta llamada a stun es hacer que el jugador se vea aturdido en la pantalla de todos los compañeros, incluyendo el compañero maestro de la red actual (debido a la llamada local a stun después de rpc("stun")).

El maestro de la bomba (el anfitrión) repite los pasos anteriores para cada uno de los cuerpos de la zona, de manera que todas las instancias de cualquier jugador de la zona de la bomba quedan aturdidas en las pantallas de todos los compañeros.

Tenga en cuenta que también puede enviar el mensaje stun() solo a un jugador específico usando rpc_id (<id>, "explotó", bomb_owner). Esto puede no tener mucho sentido para un caso de área de efecto como la bomba, pero podría en otros casos, como daño a un solo objetivo.

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

Exportando para servidores dedicados

Una vez que hayas hecho un juego multijugador, tal vez quieras exportarlo para ejecutarlo en un servidor dedicado sin GPU disponible. Ver Exportando para servidores dedicados para más información.

Nota

Los ejemplos de código de esta página no están diseñados para funcionar en un servidor dedicado. Tendrás que modificarlos para que el servidor no sea considerado como un jugador. También tendrás que modificar el mecanismo de inicio del juego para que el primer jugador que se una pueda iniciar el juego.

Nota

El ejemplo de bomberman aquí es en gran medida para fines ilustrativos, y no hace nada en el lado del anfitrión para manejar el caso en el que un compañero utiliza un cliente personalizado para engañar, por ejemplo, negándose a aturdirse a sí mismo. En la implementación actual tal engaño es perfectamente posible porque cada cliente es el maestro de la red de su propio jugador, y el maestro de la red de un jugador es el que decide si llamar al método "yo fui aturdido" (stun) en todos los otros pares y en sí mismo.