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

Высокоуровневый API против низкоуровнего

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

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

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

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

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

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

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

Примечание

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

Примечание

Подробнее о TCP/IP, UDP и сети: https://gafferongames.com/post/udp_vs_tcp/

На сайте Gaffer On Games представлено множество полезных статей о сетевых технологиях в играх (здесь), включая исчерпывающее введение в сетевые модели для игр.

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

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

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

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

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

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

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

Интерфейс этого класса может абстрагировать большинство типов сетевых уровней, топологий и библиотек. По умолчанию Godot предоставляет реализацию на основе ENet (ENetMultiplayerPeer), на основе WebRTC (WebRTCMultiplayerPeer) и на основе WebSocket (WebSocketPeer), но его также можно использовать для реализации мобильных API (для ad hoc Wi-Fi, Bluetooth) или пользовательских сетевых API, специфичных для устройств/консолей.

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

Размышления о хостинге

При запуске сервера клиенты в вашей локальной сети могут подключиться, используя внутренний IP-адрес, обычно вида 192.168.*.*. Этот внутренний IP-адрес не доступен для клиентов вне локальной сети/интернета.

В Windows вы можете найти свой внутренний IP-адрес, открыв командную строку и введя ipconfig. В macOS откройте Терминал и введите ifconfig. В Linux откройте терминал и введите ip addr.

Если вы размещаете сервер на своём компьютере и хотите, чтобы клиенты вне локальной сети могли к нему подключиться, вам, вероятно, потребуется пробросить порт сервера на вашем роутере. Это необходимо для доступности сервера из интернета, так как большинство домашних подключений используют NAT. Высокоуровневый мультиплеерный API Godot использует только UDP, поэтому вы должны пробросить порт именно для UDP, а не только для TCP.

После проброски UDP-порта и подтверждения, что ваш сервер использует этот порт, вы можете определить ваш публичный IP-адрес с помощью этого сайта. Затем сообщите этот публичный IP-адрес интернет-клиентам, желающим подключиться к вашему серверу.

Высокоуровневый мультиплеерный API Godot использует модифицированную версию ENet с полной поддержкой IPv6.

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

Высокоуровневое сетевое взаимодействие в Godot управляется SceneTree.

Каждый узел имеет свойство multiplayer, которое ссылается на экземпляр MultiplayerAPI, настроенный для него деревом сцен. Изначально каждый узел использует один и тот же объект MultiplayerAPI по умолчанию.

Можно создать новый объект MultiplayerAPI и назначить его на NodePath в дереве сцен, что переопределит свойство multiplayer для узла по этому пути и всех его потомков. Это позволяет настраивать одноуровневые узлы с разными пирами, что даёт возможность одновременно запускать сервер и клиент в одном экземпляре Godot.

# By default, these expressions are interchangeable.
multiplayer # Get the MultiplayerAPI object configured for this node.
get_tree().get_multiplayer() # Get the default MultiplayerAPI object.

Для инициализации сетевого взаимодействия необходимо создать объект MultiplayerPeer, настроить его как сервер/клиент и передать его в MultiplayerAPI.

# Create client.
var peer = ENetMultiplayerPeer.new()
peer.create_client(IP_ADDRESS, PORT)
multiplayer.multiplayer_peer = peer

# Create server.
var peer = ENetMultiplayerPeer.new()
peer.create_server(PORT, MAX_CLIENTS)
multiplayer.multiplayer_peer = peer

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

multiplayer.multiplayer_peer = null

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

При экспорте под Android обязательно включите разрешение INTERNET в пресете экспорта Android до экспорта проекта или использования одноразвёртывания. Иначе любое сетевое взаимодействие будет заблокировано системой Android.

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

Каждому пиру присваивается уникальный ID. ID сервера всегда равен 1, а клиентам назначаются случайные положительные целые числа.

Реагировать на подключения и отключения можно через подключение к сигналам MultiplayerAPI:

  • peer_connected(id: int) Этот сигнал испускается с ID новоподключённого пира на каждом другом пире, а на новом пире - несколько раз, по одному разу для каждого другого пира с его ID.

  • peer_disconnected(id: int) Этот сигнал испускается на каждом оставшемся пире при отключении одного из участников.

Остальные сигналы испускаются только на клиентах:

  • connected_to_server()

  • connection_failed()

  • server_disconnected()

Чтобы получить уникальный ID ассоциированного пира:

multiplayer.get_unique_id()

Чтобы проверить, является ли пир сервером или клиентом:

multiplayer.is_server()

Удалённые вызовы процедур (RPC)

Удалённые вызовы процедур (RPC) — это функции, которые можно вызывать на других пирах. Для создания RPC используйте аннотацию @rpc перед определением функции. Чтобы вызвать RPC, используйте метод rpc() объекта Callable для вызова на всех пирах или rpc_id() для вызова на определённом пире.

func _ready():
    if multiplayer.is_server():
        print_once_per_client.rpc()

@rpc
func print_once_per_client():
    print("I will be printed to the console once per each connected client.")

RPC не могут сериализовать объекты или вызываемые объекты (callables).

Для успешного удалённого вызова узел-отправитель и узел-получатель должны иметь одинаковый NodePath, что подразумевает одинаковые имена. При использовании add_child() для узлов, которые будут использовать RPC, установите аргумент force_readable_name в true.

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

Если функция помечена как @rpc в клиентском скрипте (или соответственно в серверном скрипте), то она должна быть объявлена и в серверном скрипте (соответственно в клиентском). Обе RPC-функции должны иметь идентичную сигнатуру, которая проверяется по контрольной сумме всех RPC в скрипте. Все RPC в скрипте проверяются одновременно, и все RPC должны быть объявлены как в клиентских, так и в серверных скриптах, даже если функции в данный момент не используются.

Сигнатура RPC включает объявление @rpc(), функцию, тип возврата и NodePath. Если RPC находится в скрипте, прикреплённом к /root/Main/Node1, то он должен находиться по точно такому же пути на узле как в клиентском, так и в серверном скрипте. Аргументы функции не проверяются на совпадение между серверным и клиентским кодом (пример: func sendstuff(): и func sendstuff(arg1, arg2): пройдут проверку сигнатуры).

Если эти условия не соблюдены (если все RPC не проходят сверку сигнатур), скрипт может вывести ошибку или вызвать нежелательное поведение. Сообщение об ошибке может быть не связано с функцией RPC, которую вы в данный момент разрабатываете и тестируете.

Дополнительные пояснения и способы устранения неполадок см. в этой публикации.

Аннотация может принимать несколько аргументов, которые имеют значения по умолчанию. @rpc эквивалентно:

@rpc("authority", "call_remote", "unreliable", 0)

Параметры и их функции приведены ниже:

mode:

  • "authority": Только мультиплеерный авторитет может вызывать удалённо. По умолчанию авторитетом является сервер, но его можно изменить для каждого узла с помощью Node.set_multiplayer_authority.

  • "any_peer": Клиентам разрешено вызывать удалённо. Полезно для передачи пользовательского ввода.

sync:

  • "call_remote": Функция не будет вызываться на локальном пире.

  • "call_local": Функция может вызываться на локальном пире. Полезно, когда сервер также является игроком.

transfer_mode:

  • "unreliable" Пакеты не подтверждаются, могут теряться и приходить в произвольном порядке.

  • "unreliable_ordered" Пакеты принимаются в порядке их отправки. Это достигается игнорированием пакетов, приходящих позже, если пакет, отправленный после них, уже получен. Может вызывать потерю пакетов при неправильном использовании.

  • "reliable": Предпринимаются попытки повторной отправки до подтверждения получения пакетов, и их порядок сохраняется. Это приводит к значительному снижению производительности.

transfer_channel — это индекс канала.

Первые три можно передавать в любом порядке, но transfer_channel всегда должен быть последним.

Функцию multiplayer.get_remote_sender_id() можно использовать для получения уникального идентификатора отправителя RPC, при использовании внутри метода, вызванного через RPC.

func _on_some_input(): # Connected to some input.
    transfer_some_input.rpc_id(1) # Send the input only to the server.


# Call local is required if the server is also a player.
@rpc("any_peer", "call_local", "reliable")
func transfer_some_input():
    # The server knows who sent the input.
    var sender_id = multiplayer.get_remote_sender_id()
    # Process the input and affect game logic.

Каналы

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

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

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

Канал по умолчанию с индексом 0 фактически представляет собой три разных канала — по одному для каждого режима передачи.

Пример реализации лобби

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

extends Node

# Autoload named Lobby

# These signals can be connected to by a UI lobby scene or the game scene.
signal player_connected(peer_id, player_info)
signal player_disconnected(peer_id)
signal server_disconnected

const PORT = 7000
const DEFAULT_SERVER_IP = "127.0.0.1" # IPv4 localhost
const MAX_CONNECTIONS = 20

# This will contain player info for every player,
# with the keys being each player's unique IDs.
var players = {}

# This is the local player info. This should be modified locally
# before the connection is made. It will be passed to every other peer.
# For example, the value of "name" can be set to something the player
# entered in a UI scene.
var player_info = {"name": "Name"}

var players_loaded = 0



func _ready():
    multiplayer.peer_connected.connect(_on_player_connected)
    multiplayer.peer_disconnected.connect(_on_player_disconnected)
    multiplayer.connected_to_server.connect(_on_connected_ok)
    multiplayer.connection_failed.connect(_on_connected_fail)
    multiplayer.server_disconnected.connect(_on_server_disconnected)


func join_game(address = ""):
    if address.is_empty():
        address = DEFAULT_SERVER_IP
    var peer = ENetMultiplayerPeer.new()
    var error = peer.create_client(address, PORT)
    if error:
        return error
    multiplayer.multiplayer_peer = peer


func create_game():
    var peer = ENetMultiplayerPeer.new()
    var error = peer.create_server(PORT, MAX_CONNECTIONS)
    if error:
        return error
    multiplayer.multiplayer_peer = peer

    players[1] = player_info
    player_connected.emit(1, player_info)


func remove_multiplayer_peer():
    multiplayer.multiplayer_peer = null
    players.clear()


# When the server decides to start the game from a UI scene,
# do Lobby.load_game.rpc(filepath)
@rpc("call_local", "reliable")
func load_game(game_scene_path):
    get_tree().change_scene_to_file(game_scene_path)


# Every peer will call this when they have loaded the game scene.
@rpc("any_peer", "call_local", "reliable")
func player_loaded():
    if multiplayer.is_server():
        players_loaded += 1
        if players_loaded == players.size():
            $/root/Game.start_game()
            players_loaded = 0


# When a peer connects, send them my player info.
# This allows transfer of all desired data for each player, not only the unique ID.
func _on_player_connected(id):
    _register_player.rpc_id(id, player_info)


@rpc("any_peer", "reliable")
func _register_player(new_player_info):
    var new_player_id = multiplayer.get_remote_sender_id()
    players[new_player_id] = new_player_info
    player_connected.emit(new_player_id, new_player_info)


func _on_player_disconnected(id):
    players.erase(id)
    player_disconnected.emit(id)


func _on_connected_ok():
    var peer_id = multiplayer.get_unique_id()
    players[peer_id] = player_info
    player_connected.emit(peer_id, player_info)


func _on_connected_fail():
    multiplayer.multiplayer_peer = null


func _on_server_disconnected():
    multiplayer.multiplayer_peer = null
    players.clear()
    server_disconnected.emit()

Корневой узел игровой сцены должен называться Game. В прикреплённом к нему скрипте:

extends Node3D # Or Node2D.



func _ready():
    # Preconfigure game.

    Lobby.player_loaded.rpc_id(1) # Tell the server that this peer has loaded.


# Called only on the server.
func start_game():
    # All peers are ready to receive RPCs in this scene.

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

После создания многопользовательской игры вы можете экспортировать её для запуска на выделенном сервере без доступного GPU. Дополнительную информацию см. в Экспортирование на выделенные серверы.

Примечание

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