Высокоуровневый мультиплеер

High-level vs low-level API

Ниже объясняются различия между высоко- и низкоуровневых сетей в Godot, а также некоторые базовые основы. Если вы хотите перейти сразу к делу и добавить сеть в свои первые узлы, перейдите к разделу Инициализация сети ниже. Но не забудьте прочитать остальные разделы позже!

Godot всегда поддерживал стандартные низкоуровневые сетевые протоколы UDP, TCP и некоторые протоколы более высокого уровня, такие как SSL и HTTP. Эти протоколы гибкие и могут быть использованы практически для всего. Однако их использование для синхронизации состояния игры вручную может быть связано с большим объемом работы. Иногда этой работы не избежать или она того стоит, например, при работе с пользовательской реализацией сервера на бэкенде. Но в большинстве случаев стоит обратить внимание на высокоуровневый сетевой API Godot, который жертвует некоторыми тонкостями низкоуровневого сетевого управления ради большей простоты использования.

Это связано с присущими ему ограничениями протоколов низкого уровня:

  • TCP гарантирует, что пакеты всегда будут поступать надежно и в правильном порядке, но задержка обычно выше из-за исправления ошибок. Это также довольно сложный протокол, потому что он понимает, что такое "соединение", и оптимизируется для целей, которые часто не подходят для приложений, таких как многопользовательские игры. Пакеты буферизуются для отправки большими партиями, что снижает накладные расходы на пакеты для увеличения задержки. Это может быть полезно для таких вещей, как HTTP, но обычно не для игр. Некоторые из них можно настроить и отключить (например, отключив "алгоритм Nagle" для TCP-подключения).

  • UDP - это более простой протокол, который только отправляет пакеты (и не имеет понятия "соединение"). Отсутствие коррекции ошибок делает его довольно быстрым (низкая задержка), но пакеты могут быть потеряны по пути или получены в неправильном порядке. Кроме того, MTU (максимальный размер пакета) для UDP обычно невелик (всего несколько сотен байт), поэтому передача больших пакетов означает их разделение, реорганизацию и повторную попытку в случае неудачи.

В целом, TCP можно считать надежным, упорядоченным и медленным; UDP - ненадежным, неупорядоченным и быстрым. Из-за большой разницы в производительности часто имеет смысл перестроить те части TCP, которые нужны для игр (необязательная надежность и порядок пакетов), избегая при этом ненужных частей (функции контроля перегрузок/трафика, алгоритм Нагла и т.д.). В связи с этим большинство игровых движков поставляются с такой реализацией, и Godot не является исключением.

In summary, you can use the low-level networking API for maximum control and implement everything on top of bare network protocols or use the high-level API based on SceneTree that does most of the heavy lifting behind the scenes in a generally optimized way.

Примечание

Большинство поддерживаемых Godot платформ предлагают все или большинство из перечисленных высоко- и низкоуровневых сетевых функций. Однако, поскольку сетевое взаимодействие всегда в значительной степени зависит от аппаратного обеспечения и операционной системы, некоторые функции могут быть изменены или недоступны на некоторых целевых платформах. В частности, платформа HTML5 в настоящее время предлагает поддержку WebSockets и WebRTC, но не имеет некоторых высокоуровневых функций, а также необработанного доступа к низкоуровневым протоколам, таким как TCP и UDP.

Примечание

More about TCP/IP, UDP, and networking: 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.

Если вы хотите использовать низкоуровневую сетевую библиотеку вместо встроенной сети Godot, см. здесь пример: https://github.com/PerduGames/gdnet3

Предупреждение

Добавление сети в игру сопряжено с некоторой ответственностью. Это может сделать ваше приложение уязвимым, может привести к использованию читов или эксплойтов. Это может даже позволить злоумышленнику скомпрометировать машины, на которых работает ваше приложение, и использовать ваши серверы для рассылки спама, атаки других или кражи ваших пользовательских данных, если они играют в вашу игру.

Это тот случай, когда сеть существует и не имеет ничего общего с Godot. Конечно, вы можете экспериментировать, но когда вы выпускаете сетевое приложение, всегда заботьтесь о любых возможных проблемах безопасности.

Среднеуровневая абстракция

Прежде чем перейти к тому, как синхронизировать игру по сети, может быть полезно понять, как работает базовый сетевой API для синхронизации.

Godot использует среднеуровневый объект NetworkedMultiplayerPeer. Этот объект не предназначен для непосредственного создания, но разработан таким образом, чтобы несколько реализаций C++ могли предоставить его.

Этот объект простирается от PacketPeer таким образом, он наследует все полезные методы сериализации, отправки и получения данных. Кроме того, он добавляет методы для установки однорангового узла, режима передачи и т. д. Она также включает в себя сигналы, которые позволят вам знать, когда участник сети подключается или отключается.

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 ad hoc WiFi, Bluetooth) or custom device/console-specific networking APIs.

В большинстве случаев использование этого объекта напрямую не рекомендуется, так как Godot предоставляет сетевые средства еще более высокого уровня. Тем не менее, он доступен в случае, если у игры есть конкретные потребности в API более низкого уровня.

Инициализация сети

Объект, который управляет сетью в Godot-это тот же самый объект, который управляет всем, что связано с деревом: SceneTree.

To initialize high-level networking, the SceneTree must be provided a NetworkedMultiplayerPeer object.

To create that object, it first has to be initialized as a server or client.

Инициализация сервера, прослушивание, заданного порта, с неким максимальным количеством подключений:

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

Инициализация клиента, подключение к указанному IP и порту:

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

Проверка инициализации дерева (клиент или сервер):

get_tree().is_network_server()

Закрытие сетевых функции :

get_tree().network_peer = null

(Хотя, возможно, имеет смысл сначала отправить сообщение, чтобы другие участники знали, что вы уходите, вместо того, чтобы закрывать соединение, зависит от вашей игры.)

Предупреждение

When exporting to Android, make sure to enable the INTERNET permission in the Android export preset before exporting the project or using one-click deploy. Otherwise, network communication of any kind will be blocked by Android.

Управление соединениями

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:

Сервер и клиенты:

  • network_peer_connected(int id)

  • network_peer_disconnected(int id)

Вышеуказанные сигналы вызываются на каждом узле, подключенном к серверу (в том числе на сервере), когда новый узел подключается или отключается. Клиенты будут подключаться с уникальным ID больше 1, в то время как ID 1 всегда у сервера. Все что ниже 1 должны рассматриваться как недействительные. Вы можете получить ID локальной системы с помощью SceneTree.get_network_unique_id(). Эти идентификаторы будут полезны в основном для управления лобби и, как правило, должны храниться, поскольку они идентифицируют подключенные узлы и, следовательно, игроков. Можно также использовать идентификаторы для отправки сообщений только определенным узлам.

Клиенты:

  • connected_to_server

  • connection_failed

  • server_disconnected

Again, all these functions are mainly useful for lobby management or for adding/removing players on the fly. For these tasks, the server clearly has to work as a server and you have to perform tasks manually such as sending a newly connected player information about other already connected players (e.g. their names, stats, etc).

Лобби можно реализовать любым удобным для Вас способом, но наиболее распространенным способом является использование узла с одинаковым именем во всех одноранговых сценах. Как правило, автоматически загружаемый узел/синглтон отлично подходит для этого, чтобы всегда иметь доступ, например, "/root/lobby".

RPC

Для связи между узлами проще всего использовать RPC (удаленные вызовы процедур). Это реализовано в виде набора функций в: 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>)

Синхронизация переменных также возможна:

  • rset("variable", value)

  • rset_id(<peer_id>, "variable", value)

  • rset_unreliable("variable", value)

  • rset_unreliable_id(<peer_id>, "variable", value)

Функции можно вызывать двумя способами:

  • Reliable: when the function call arrives, an acknowledgement will be sent back; if the acknowledgement isn't received after a certain amount of time, the function call will be re-transmitted.

  • Unreliable: the function call is sent only once, without checking to see if it arrived or not, but also without any extra overhead.

В большинстве случаев требуется надежность. Ненадежный в основном полезен при синхронизации позиций объектов (синхронизация должна происходить постоянно, и если пакет потерян, это не так уж плохо, потому что новый в конечном итоге прибудет, и он, вероятно, будет устаревшим, потому что объект перемещается всё дальше).

There is also SceneTree.get_rpc_sender_id(), which can be used to check which peer (or peer ID) sent an RPC.

Вернёмся к лобби

Давайте вернёмся к теме лобби. Представьте, что каждый игрок, подключившийся к серверу, расскажет нам об этом.

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

This keyword is one of many that allow a function to be called by a remote procedure call (RPC). There are six of them total:

  • remote

  • remotesync

  • puppet

  • puppetsync

  • master

  • mastersync

Each of them designate who can call the rpc, and optionally sync if the RPC can be called locally.

Примечание

If no rpc keywords are added, Godot will block any attempts to call functions remotely. This makes security work a lot easier (so a client can't call a function to delete a file on another client's system).

The remote keyword can be called by any peer, including the server and all clients. The puppet keyword means a call can be made from the network master to any network puppet. The master keyword means a call can be made from any network puppet to the network master.

If sync is included, the call can also be made locally. For example, to allow the network master to change the player's position on all peers:

puppetsync func update_position(new_position):
    position = new_position

Совет

You can also use SceneTree.get_rpc_sender_id() to have more advanced rules on how an rpc can be called.

These keywords are further explained in Synchronizing the game.

With this, lobby management should be more or less explained. Once you have your game going, you will most likely want to add some extra security to make sure clients don't do anything funny (just validate the info they send from time to time, or before game start). For the sake of simplicity and because each game will share different information, this is not shown here.

Starting the game

Once enough players have gathered in the lobby, the server should probably start the game. This is nothing special in itself, but we'll explain a few nice tricks that can be done at this point to make your life much easier.

Player scenes

In most games, each player will likely have its own scene. Remember that this is a multiplayer game, so in every peer you need to instance one scene for each player connected to it. For a 4 player game, each peer needs to instance 4 player nodes.

So, how to name such nodes? In Godot, nodes need to have a unique name. It must also be relatively easy for a player to tell which node represents each player ID.

The solution is to simply name the root nodes of the instanced player scenes as their network ID. This way, they will be the same in every peer and RPC will work great! Here is an example:

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

Примечание

Depending on when you execute pre_configure_game(), you may need to change any calls to add_child() to be deferred via call_deferred(), as the SceneTree is locked while the scene is being created (e.g. when _ready() is being called).

Synchronizing game start

Setting up players might take different amounts of time for every peer due to lag, different hardware, or other reasons. To make sure the game will actually start when everyone is ready, pausing the game until all players are ready can be useful:

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)

When the server gets the OK from all the peers, it can tell them to start, as for example:

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!

Synchronizing the game

In most games, the goal of multiplayer networking is that the game runs synchronized on all the peers playing it. Besides supplying an RPC and remote member variable set implementation, Godot adds the concept of network masters.

Network master

The network master of a node is the peer that has the ultimate authority over it.

When not explicitly set, the network master is inherited from the parent node, which if not changed, is always going to be the server (ID 1). Thus the server has authority over all nodes by default.

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.

If you have paid attention to the previous example, it's possible you noticed that each peer was set to have network master authority for their own player (Node) instead of the server:

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

Each time this piece of code is executed on each peer, the peer makes itself master on the node it controls, and all other nodes remain as puppets with the server being their network master.

To clarify, here is an example of how this looks in the bomber demo:

../../_images/nmms.png

Master and puppet keywords

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:

Example bomb code:

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

Example player code:

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.

Ключевое слово master в методе exploded в узле игрока означает две вещи для того, как выполняется этот вызов. Во-первых, с точки зрения вызывающего аналога (хоста), вызывающий аналог будет пытаться удаленно вызвать метод только на том аналоге, который он установил в качестве сетевого мастера данного узла игрока. Во-вторых, с точки зрения пира, которому посылает вызов хост, пир примет вызов только в том случае, если он установил себя в качестве хозяина сети узла игрока с вызываемым методом (который имеет ключевое слово master). Это работает хорошо до тех пор, пока все пиры согласны с тем, кто является хозяином чего-либо.

Вышеприведенная установка означает, что только пир, владеющий пораженным телом, будет отвечать за сообщение всем остальным пирам о том, что его тело было оглушено, после того, как получит удаленное указание сделать это от бомбы хозяина. Поэтому владелец (все еще в методе взрыв) сообщает всем другим равным, что его узел игрока был оглушен. Он делает это, удаленно вызывая метод stun для всех экземпляров этого узла игрока (на других узлах). Поскольку метод stun имеет ключевое слово puppet, его вызовут только те пиры, которые не установили себя в качестве сетевого хозяина узла (другими словами, эти пиры являются марионетками для этого узла в силу того, что не являются его сетевым хозяином).

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

Экспортирование на выделенные серверы

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 Экспортирование на выделенные серверы for more information.

Примечание

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.

Примечание

Пример с бомбардировщиком здесь приведён в основном в иллюстративных целях, и на стороне хоста ничего не делается для того, чтобы справиться со случаем, когда одноклассник использует пользовательский клиент для обмана, например, отказываясь оглушать себя. В текущей реализации такое жульничество вполне возможно, поскольку каждый клиент является хозяином сети своего игрока, а хозяин сети игрока - это тот, кто решает, вызывать ли метод I-was-stunned (stun) для всех других пиров и себя.