Multiplayer 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

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

Gaffer On Games tiene muchos artículos útiles 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 puedan proporcionarlo:

../../_images/nmpeer.png

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), pero esto podría usarse para implementar APIs móviles (para adhoc WiFi, 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().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

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

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 a función normal).

Los otros serán explicados más abajo. Tenga en cuenta que también 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.
    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.

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

Comprobar que una instancia de nodo específica en un peer es la red maestra para este nodo, para todos los peers conectados se hace llamando a Node.is_network_master(). Este retornara verdadero cuando se ejecute en el servidor y falso 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() # Stun myself, could have used remotesync keyword too.

En el ejemplo anterior, una bomba explota en algún lugar (probablemente sea administrada por quien sea master). La bomba conoce los cuerpos en el área, por lo que los verifica y verifica que contengan una función “exploded”.

Si lo tienen, la bomba llama “exploded” en él. Sin embargo, el método exploded en el jugador tiene una palabra clave`master`. Esto significa que solo el jugador que es master para esa instancia realmente obtendrá la función.

Esta instancia, entonces, llama a la función stun en las mismas instancias de ese mismo jugador (pero en diferentes peers), y solo aquellos que se establecen como puppet, haciendo que el jugador se vea aturdido en todos los peers (así como en el actual , único master).

Ten en cuenta que también puedes enviar el mensaje stun() solo a un jugador específico utilizando rpc_id(<id>, «exploded», bomb_owner). Esto puede no tener mucho sentido para un caso de área de efecto como el de la bomba, sino en otros casos, como daño a un solo objetivo.

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