Мультиплеєр високого рівня

API високого та низького рівня

Нижче пояснюються відмінності мереж високого та низького рівня у Godot, а також деякі основи. Якщо ви хочете одразу перейти до справи і додати мережу до своїх перших вузлів, перейдіть до Ініціалізація мережі нижче. Але пізніше обов’язково прочитайте решту!

Godot завжди підтримував стандартні мережі низького рівня через UDP, TCP і деякі протоколи високого рівня, такі як SSL і HTTP. Ці протоколи є гнучкими і можуть використовуватися практично для чого завгодно. Однак використання їх для ручної синхронізації стану гри може вимагати великого об'єму роботи. Іноді цієї роботи неможливо уникнути, або вона того варта, наприклад, при роботі з власною реалізацією сервера на сервері. Але в більшості випадків варто розглянути в Godot API мережі високого рівня, який нехтує частиною тонкого контролю мережі низького рівня заради простоти використання.

Це пов'язано з обмеженнями притаманними протоколам низького рівня:

  • TCP гарантує, що пакети завжди надходитимуть надійно та в правильному порядку, але затримка, як правило, вища через виправлення помилок. Це також досить складний протокол, оскільки він розуміє, що таке "з'єднання", і оптимізується для цілей, які часто не підходять для таких застосунків, як багатокористувацькі ігри. Пакети буферизуються для надсилання більшими пачками, що призводить до менших затрат на пакет, але більшої затримки. Це може бути корисно для таких речей, як HTTP, але, зазвичай, не для ігор. Дещо з цього можна налаштувати та вимкнути (наприклад, відключивши «алгоритм Нейгла» для TCP-з'єднання).

  • UDP є простішим протоколом, який надсилає лише пакети (і не має поняття "з’єднання"). Відсутність виправлення помилок робить його досить швидким (затримка низька), але пакети можуть бути втрачені при передачі, або отримані в неправильному порядку. Крім того, MTU (максимальний розмір пакету) для UDP, як правило, малий (всього кілька сотень байтів), тому передача більших пакетів означає їх розділення, реорганізацію та повторну спробу, якщо частина дає збій.

Загалом, TCP можна вважати надійним, впорядкованим і повільним; UDP — ненадійним, неупорядкованим, але швидким. Через велику різницю в продуктивності часто є сенс перебудувати частини TCP, необхідні для ігор (додаткова надійність і порядок пакетів), уникаючи небажаних частин (функції контролю завантаженості/трафіку, алгоритм Нейгла тощо). Завдяки цьому більшість ігрових рушіїв мають таку реалізацію, і Godot не виняток.

Отже, ви можете використовувати API мережі низького рівня для максимального контролю та впроваджувати все поверх відкритих протоколів мережі, або використовувати API високого рівня на основі Дерева Сцени, яке, загалом оптимізовано, виконує більшість важких завдань за лаштунками.

Примітка

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

Примітка

Більше про TCP/IP, UDP та мережу: https://gafferongames.com/post/udp_vs_tcp/

У Gaffer On Games є багато корисних статей про мережу в іграх (`тут<https://gafferongames.com/categories/game-networking/>`__), включаючи вичерпуюче `введення в мережеві моделі в іграх<https://gafferongames.com/post/what_every_programmer_needs_to_know_about_game_networking/>`__.

Якщо ви хочете використовувати свою мережеву бібліотеку низького рівня замість вбудованої мережі Godot, перегляньте тут зразок: https://github.com/PerduGames/gdnet3

Попередження

Додавання мережі до вашої гри пов'язане з певною відповідальністю. Це може зробити вашу програму вразливою, якщо її зробити неправильно, і може призвести до читів, або експлойтів. Це може навіть дозволити зловмиснику скомпрометувати машини, на яких працює ваша програма, і використовувати ваші сервери для розсилки спаму, нападу на інших, або крадіжки даних ваших користувачів, якщо вони грають у вашу гру.

Таке завжди відбувається, коли задіяна мережа і це не має ніякого відношення до Godot. Ви, звичайно, можете експериментувати, але завжди, коли ви випускаєте мережеву програму, подбайте про всі можливі заходи безпеки.

Абстракція середнього рівня

Перш ніж перейти до того, як ми можемо синхронізувати гру в мережі, може бути корисно зрозуміти, як виконує синхронізацію базовий мережевий API.

Godot використовує об’єкт середнього рівня NetworkedMultiplayerPeer. Цей об’єкт не призначений для безпосереднього створення, він розроблений так, щоб його могли надати кілька реалізацій C++.

Цей об’єкт поширюється від PacketPeer, тому він успадковує всі корисні методи для серіалізації, надсилання та отримання даних. Крім того, він додає методи встановлення однорангового вузла, режиму передачі тощо. Він також включає сигнали, які повідомлять вам, коли однорангові вузли підключаються, чи відключаються.

Цей інтерфейс класу може абстрагувати більшість типів мережевих рівнів, топологій і бібліотек. За замовчуванням Godot надає реалізацію на основі ENet (NetworkedMultiplayerEnet), одну на основі WebRTC (WebRTCMultiplayer), і одну на основі WebSocket (WebSocketMultiplayerPeer), але її можна використовувати для реалізації мобільних API (для спеціальних WiFi, Bluetooth), або власних мережевих API для пристроїв/консолей.

У більшості випадків не рекомендується використовувати цей об’єкт безпосередньо, оскільки Godot забезпечує мережеві засоби ще більш високого рівня. Проте він доступний на випадок, якщо гра має конкретні потреби в API нижчого рівня.

Ініціалізація мережі

Об’єкт, який керує мережею в Godot, є тим самим, що керує всім, що пов’язано з Деревом Сцени.

Щоб ініціалізувати мережу високого рівня, Дереву Сцени має бути наданий об’єкт NetworkedMultiplayerPeer.

Щоб створити цей об’єкт, його спочатку потрібно ініціалізувати як сервер, або клієнт.

Ініціалізація в якості сервера, прослуховування на заданому порту з заданою максимальною кількістю однорангових серверів:

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_tree().get_network_peer()

Перевірка того, чи дерево ініціалізовано, як сервер, чи як клієнт:

get_tree().is_network_server()

Закриття функцій мережі:

get_tree().network_peer = null

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

Попередження

Під час експорту в Android обов’язково ввімкніть дозвіл INTERNET у попередньо налаштованих експортах Android, перш ніж експортувати проект, або використовувати розгортання одним клацанням мишки. Інакше Android заблокує будь-який мережевий зв’язок.

Управління з'єднаннями

Деякі ігри приймають з'єднання в будь-який час, інші під час фази лобі. Godot, в будь-який момент, можна попросити більше не приймати з’єднання (див. set_refuse_new_network_connections(bool) і пов’язані методи на Дереві Сцени). Щоб керувати з'єднаннями, Godot надає такі сигнали в Дереві Сцени:

Сервер і клієнти:

  • network_peer_connected(int id)

  • network_peer_disconnected(int id)

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

Клієнти:

  • connected_to_server

  • connection_failed

  • server_disconnected

Знову ж таки, всі ці функції в основному корисні для керування лобі, або для додавання/вилучення гравців на льоту. Для цих завдань сервер, безсумнівно, повинен працювати як сервер, і ви повинні виконувати завдання вручну, наприклад надсилати щойно підключеному гравцеві інформацію про інших уже підключених гравців (наприклад, їх імена, статистику тощо).

Лобі можна реалізувати як завгодно, але найпоширенішим способом є використання вузла з однаковою назвою в сценах у всіх однорангових вузлах. Як правило, для цього чудово підходить автоматично завантажений вузол/синглтон, щоб завжди мати доступ, наприклад, "/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)

Функції можна викликати двома способами:

  • Надійно: коли виклик функції надійде, назад буде надіслано підтвердження; якщо підтвердження не буде отримано через певний час, виклик функції буде передано повторно.

  • Ненадійно: виклик функції надсилається лише один раз, без перевірки того, чи надійшов він чи ні, але також без зайвих накладних витрат.

У більшості випадків потрібен надійний. Ненадійний в основному корисний під час синхронізації позицій об’єктів (синхронізація має відбуватися постійно, і якщо пакет буде втрачено, це не так вже й погано, тому що врешті-решт надійде новий, і втрачений, скоріше за все, буде застарілим, оскільки об’єкт тим часом рухався далі).

Існує також SceneTree.get_rpc_sender_id(), який можна використовувати для перевірки того, який одноранговий вузол (або ідентифікатор однорангового вузла) надіслав 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

Можливо, ви вже помітили щось нове, а саме використання ключа remote в функції register_player:

remote func register_player(info):

Це ключове слово є одним з багатьох, які дозволяють викликати функцію за допомогою віддаленого виклику процедури (RPC). Всього їх є шість:

  • remote

  • remotesync

  • puppet

  • puppetsync

  • master

  • mastersync

Кожен з них вказує, хто може викликати rpc, і додатково sync, якщо RPC можна викликати локально.

Примітка

Якщо не додати ключові слова rpc, Godot заблокує будь-які спроби віддаленого виклику функцій. Це значно спрощує роботу служби безпеки (так, клієнт не зможе викликати функцію для видалення файлу в системі іншого клієнта).

Ключове слово remote може бути викликане будь-яким одноранговим вузлом, включаючи сервер і всіх клієнтів. Ключове слово puppet означає, що виклик може бути здійснений з головного пристрою мережі до будь-якої мережевої маріонетки. Ключове слово master означає, що виклик може бути здійснений з будь-якої мережевої маріонетки до головного вузла мережі.

Якщо включено sync, виклик також можна здійснювати локально. Наприклад, для того, щоб майстер мережі міг змінювати позицію гравця на всіх однорангових пристроях:

puppetsync func update_position(new_position):
    position = new_position

Порада

Ви також можете використовувати SceneTree.get_rpc_sender_id(), щоб мати більш розширені правила виклику rpc.

Ці ключові слова додатково пояснено у Синхронізації гри.

На цьому управління лобі має бути більш-менш зрозумілим. Коли ви запустите гру, ви, швидше за все, захочете додати додатковий захист, щоб переконатися, що клієнти не роблять нічого сумнівного (просто перевіряйте інформацію, яку вони надсилають час від часу, або перед початком гри). Для простоти та через те, що в кожній грі буде різна інформація, це не показано тут.

Як розпочати гру

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

Сцени гравців

У більшості ігор кожен гравець, так би мовити, буде мати свою власну сцену. Пам’ятайте, що це багатокористувацька гра, тому в кожному однорангові вузлі вам потрібно створити одну сцену для кожного під’єднаного до нього гравця. Для гри з 4 гравців кожен одноранговий вузол має створити 4 вузли гравців.

Отже, як назвати такі вузли? У Godot вузли повинні мати унікальну назву. Гравець також повинен відносно легко визначити, який вузол представляє ідентифікатор кожного гравця.

Рішення полягає в тому, щоб просто називати кореневі вузли створюваних сцен гравців так само, як ідентифікатор їх мережі. Таким чином, вони будуть однаковими для всіх однорангових вузлів і RPC працюватиме чудово! Ось приклад:

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

Примітка

Залежно від того, коли ви виконуєте pre_configure_game(), вам може знадобитися відкласти деякі виклики add_child() за допомогою call_deferred(), оскільки Дерево Сцен заблоковано під час створення сцени (наприклад, коли викликається _ready()).

Синхронізація початку гри

Налаштування гравців може зайняти різну кількість часу для кожного однорангового вузла через затримку, різне обладнання, чи інші причини. Щоб переконатися, що гра почнеться, коли всі будуть готові, корисно призупинити гру, доки всі гравці не будуть готові:

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)

Коли сервер отримує ДОБРО від усіх однорангових вузлів, він може дати їм вказівку починати, наприклад:

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!

Синхронізація гри

У більшості ігор мета мультиплеєрної мережі полягає в тому, щоб синхронізувати гру по усіх однорангових вузлах, які в неї грають. Окрім реалізації RPC та віддалених змінних членів, Godot додає концепцію майстрів мережі.

Майстер мережі

Майстер мережі — це одноранговий вузол, який має атворитетні повноваження.

Якщо його не встановлено явно, майстер мережі успадковується від батьківського вузла, який, якщо його не змінити, завжди є сервером (ідентифікатор 1). Таким чином, за замовчуванням сервер має повноваження над усіма вузлами.

Майстра мережі можна встановити за допомогою функції Node.set_network_master(id, recursive) (recursive за замовчуванням рівне true і означає, що майстер мережі також рекурсивно встановлюється на всіх дочірніх вузлах цього вузла).

Перевірка того, чи конкретний екземпляр вузла на одноранговому вузлі є майстром мережі, виконується за допомогою виклику Node.is_network_master(). Виконанні на сервері поверне true, а на однорангових вузлах поверне false.

Якщо ви звернули увагу на попередній приклад, можливо, ви помітили, що кожен одноранговий вузол був налаштований на повноваження майстра мережі для свого гравця (Вузла) замість сервера:

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

Кожного разу, коли цей фрагмент коду виконується на кожному однорангові вузлі, цей вузол стає сам собі майстром, а всі інші вузли залишаються маріонетками сервера, який є їх майстром мережі.

Щоб уточнити, ось приклад того, як це виглядає в `демо-версії бомбардувальника<https://github.com/godotengine/godot-demo-projects/tree/master/networking/multiplayer_bomber>`_:

../../_images/nmms.png

Ключові слова master та puppet

Справжня перевага цієї моделі полягає у використанні ключових слів master/puppet у GDScript (або їх еквівалентів у C# та Visual Script). Подібно до ключового слова remote, ними також можна позначати функції:

Приклад коду бомби:

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

Приклад коду гравця:

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

У наведеному вище прикладі бомба десь вибухає (імовірно, керована власником цього бомбового вузла, наприклад хостом). Бомба знає тіла (вузли гравців) у цій області, тому вона перевіряє, чи вони містять метод exploded, перш ніж викликати його.

Нагадаємо, що кожен одноранговий вузол має повний набір екземплярів вузлів гравця, по одному екземпляру для кожного однорангового вузла (включаючи себе та хост). Кожен одноранговий вузол встановив себе як майстра відповідного собі екземпляра, і він встановив іншого однорангового вузла як майстра для кожного іншого екземпляра.

Тепер повернімося до виклику методу exploded, бомба на хості викликала його віддалено на всіх тілах в області, які мають цей метод. Однак цей метод знаходиться у вузлі гравця і має ключове слово master.

Ключове слово master на методі exploded у вузлі гравця означає дві речі для того, як здійснюється цей виклик. По-перше, з точки зору однорангового вузла, який робить виклик (хост), він лише намагатиметься віддалено викликати метод на однорангових вузлах, які він встановив як майстри мережі відповідного вузла гравця. По-друге, з точки зору однорангового вузла, якому хост надсилає виклик, одноранговий вузол прийме виклик лише в тому випадку, якщо він встановив себе як майстра мережі вузла гравця з методом, який викликається (який має ключове слово master). Це добре працює, якщо всі однорангові вузли погоджуються з тим, хто чому є майстром.

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

Виклик stun змушує гравця виглядати ураженим на екранах всіх однорангових вузлів, включаючи поточний одноранговий вузол майстра мережі (через локальний виклик stun після rpc("stun")).

Майстер бомби (хост) повторює вищезазначені кроки для кожного тіла в зоні, так що всі екземпляри будь-якого гравця в зоні бомби уражуються на екранах усіх однорангових вузлів.

Зауважте, що ви також можете надіслати повідомлення stun() лише певному гравцеві за допомогою rpc_id(<id>, "exploded", bomb_owner). Це може не мати великого сенсу для об'ємних ефектів, як-от бомба, але може мати сенс в інших випадках, наприклад, ушкодження однієї цілі.

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

Експорт для виділених серверів

Після створення багатокористувацької гри ви можете експортувати її для запуску на виділеному сервері без доступного графічного процесора. Дивіться Експорт для виділених серверів для додаткової інформації.

Примітка

Зразки коду на цій сторінці не призначені для роботи на виділеному сервері. Вам доведеться змінити їх, щоб сервер не вважався гравцем. Вам також доведеться змінити механізм початку гри, щоб перший гравець, який приєднається, міг почати гру.

Примітка

Приклад Bomberman тут переважно для ілюстративних цілей і не робить нічого на стороні хоста, щоб обробити випадок, коли одноранговий вузол користувача використовує користувацький клієнт для шахрайства, наприклад, відмовляючись оглушити себе. У поточній реалізації такий обман цілком можливий, тому що кожен клієнт є майстром мережі свого власного гравця, а мережевий майстер гравця — це той, хто вирішує, чи викликати метод "Я-був-уражений" (stun) для всіх інших однорангових вузлів і себе.