Skip to content

09 — Мультиплеєр

🇬🇧 English | 🇺🇦 Українська

Головна обіцянка плагіна: ви пишете код один раз, і він працює як в одиночній грі, так і в мультиплеєрі. Цей розділ пояснює, як це влаштовано і де є єдиний виняток.


Один метод замість двох

Inventory->TryAddItem(Definition, 3, Remainder);

Усередині відбувається лише одна перевірка:

bool UISInventoryComponent::TryAddItem(...)
{
    if (!HasContainerAuthority())
    {
        Server_AddItem(Definition, Count);   // клієнт -> сервер
        return true;                          // "запит надіслано"
    }
    return AddItem_Internal(...);             // маємо владу - працюємо на місці
}
Ситуація HasAuthority() Що відбувається
Одиночна гра true виконується на місці, RPC не створюється
Listen-сервер (хост) true так само, на місці
Клієнт false пересилання на сервер, результат — реплікацією

Одиночна гра не серіалізує жодного байта. Мультиплеєрна здатність нічого їй не коштує.


Повернене значення на клієнті

Клієнт не може дізнатися результат синхронно — сервер ще не відповів. Тому:

На клієнті Try* повертає true у значенні «запит надіслано», а не «успішно виконано».

// НЕПРАВИЛЬНО - на клієнті це завжди true
if (Inventory->TryAddItem(Def, 1, Rem))
{
    ShowPickupAnimation();   // спрацює навіть коли сервер відмовить
}

// ПРАВИЛЬНО - реагуємо на подію, що приходить із реплікацією
Inventory->OnItemAdded.AddDynamic(this, &UMyWidget::HandleItemAdded);

Інтерфейс завжди будують на подіях: OnInventoryChanged, OnItemAdded, OnItemRemoved, OnItemChanged, OnAddRejected. Вони спрацьовують і на сервері, і на клієнтах, тож той самий обробник обслуговує обидва режими.


Виняток: контейнери без власника

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

Проблема. Server RPC працює лише тоді, коли актор має мережеве з'єднання, через яке запит може дійти до сервера. Персонаж гравця його має. Скриня посеред рівня — ні. Рушій мовчки відкидає такий виклик.

// НЕ ПРАЦЮЄ на клієнті: скриня не має з'єднання
ChestInventory->TryRemoveItemFromSlot(0, 1);

Рішення. Запит завжди йде через інвентар гравця, у якого з'єднання є. Сервер, отримавши його, авторитетно рухає предмети в обох інвентарях:

// ПРАЦЮЄ скрізь
MyInventory->TryTransferFrom(ChestInventory, SlotIndex);   // забрати
MyInventory->TryTransferTo(ChestInventory, SlotIndex);     // покласти

У Blueprint є вузол, що обирає напрямок сам:

Move Item Between Actors (From Actor, To Actor, From Slot)

Правило, яке варто запам'ятати: будь-яка взаємодія зі світовим контейнером ініціюється інвентарем гравця.

Предмети на землі — той самий принцип, але вам його вже зробили. У пікапа теж немає з'єднання, тож замість Pickup->TryCollect(Player) викликайте:

// ПРАЦЮЄ скрізь: маршрут іде через інвентар гравця
MyInventory->TryCollect(Pickup);

Це звичайний Try* — з клієнта пересилається сам. AISItemPickup::TryCollect теж існує, але це низькорівневий серверний виклик, у який попередній і потрапляє.

Що лишається серверним

Три речі не маршрутизують себе, бо в них немає через що:

Виклик Що робити натомість
AISLootContainer::Open / Close викликати з серверного боку вашої взаємодії
AISItemPickup::SpawnForItem викидання предмета — Server RPC на гравцеві, що викидає
UISInventoryComponent::TryAddItemInstance клієнт не може передати серверу вказівник на об'єкт; використовуйте TryTransferFrom або TryCollect

Усі три відмовляють на клієнті й пишуть у лог — мовчки не ламається нічого.


Що заважає клієнтові попросити зайве

Схема вище дозволяє клієнтові сказати «перенеси слот 3 того інвентаря до мого». У формі цього речення немає нічого, що завадило б назвати рюкзак іншого гравця — тож про це дбає сервер.

Кожен Server_* RPC перед будь-якою роботою проходить через UISItemContainerComponent::CanAcceptRequestFrom. Хто саме надіслав запит, береться зі з'єднання, на яке RPC прийшов, а не з аргументу — аргумент якраз і є тим, що модифікований клієнт контролює повністю.

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

Типова реалізація приймає запит, якщо виконано одне з двох:

  • контейнер належить тому самому гравцеві, що надіслав запит (порівнюються UNetConnection, тож контролер, пішак і PlayerState однаково підходять);
  • у контейнера взагалі немає з'єднання — це світовий контейнер (скриня, труп, торговий автомат), і він відкритий кожному, хто до нього дістався;

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

[/Script/InventorySystem.ISInventorySettings]
; Серверна межа тверезості, а не дальність взаємодії: беріть із запасом у кілька
; разів більшим за вашу реальну дальність, щоб звичайний пінг її не зачіпав.
; 0 (типово) вимикає перевірку.
MaxInteractionDistance=1000.0

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

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


Що і як реплікується

Вміст інвентаря

FISInventoryList побудований на FFastArraySerializer: змінюється один слот — надсилається один слот, а не весь інвентар. Різниця між 40 байтами й кількома кілобайтами, коли гравець підняв камінець.

Предмети (UISItemInstance) — реплікований UObject-підоб'єкт. Плагін підтримує обидва механізми реплікації підоб'єктів UE і слідує тому, який обрав ваш проєкт:

  • класичний ReplicateSubobjects (типово в UE 5.7);
  • реєстр підоб'єктів, якщо проєкт увімкнув net.SubObjects.DefaultUseSubObjectReplicationList.

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

Місткість інвентаря

MaxSlots і MaxWeight реплікуються разом із вмістом. Це важливо саме тоді, коли місткість змінюється під час гри, і ось чому.

Значення, задане в Blueprint, приїжджає до клієнтів само собою — воно лежить у дефолтах класу, і клієнт створює компонент уже з ним. А от присвоєння в рантаймі дефолти не змінює: сервер робить Inventory->MaxSlots = 40, і без реплікації клієнт лишався б із тим числом, з яким компонент народився.

Наслідки такого розходження — суто клієнтські, але помітні: IsFull(), GetFreeSlotCount(), IsValidSlot() і CanAcceptItemCount() на клієнті відповідали б за старим розміром, сітка малювалася б не на ту кількість комірок, а перетягування у слот 35 підсвічувалося б як заборонене. Самі предмети при цьому не губляться ніколи — усі мутації серверні, і сервер завжди рахує правильно.

Це стосується двох цілком звичайних сценаріїв:

  • апгрейд рюкзака — перк, квестова нагорода, нова сумка;
  • автостворення компонентів — підсистема застосовує DefaultInventorySlots на сервері, тобто теж у рантаймі.

Спорядження

Так само через FFastArraySerializer. Візуальний актор не реплікується — кожна машина створює свій із фрагмента Equippable. Тому меч у руці коштує рівно стільки ж, скільки запис про слот.

Хто що бачить

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

Якщо вам потрібна сувора приватність інвентаря гравця, обгорніть доступ у своєму UI — плагін не приховує дані на рівні реплікації.


Одиночна гра й мультиплеєр на одній карті

Нічого перемикати не треба. Компоненти створюються там, де світ має владу: в одиночній грі це єдиний світ, у мережевій — сервер, а клієнти отримують репліки.

Жодна гілка вашого коду не має питати, у якому режимі вона працює.


Автоматичне під'єднання компонентів

UISInventoryWorldSubsystem може сам створювати компоненти кожному гравцеві. За замовчуванням вимкнено — див. 02 — Інтеграція без налаштування.

Один механізм покриває вхід у гру, пізнє під'єднання, respawn, seamless travel і PIE. Вручну розміщені компоненти завжди мають перевагу.

Де саме створювати:

Auto Inventory Host Коли доречно
PlayerState інвентар переживає смерть і respawn — типовий рюкзак
Pawn інвентар губиться разом із тілом — extraction-шутери, рогалики

Перевірка в мультиплеєрі

У редакторі: Play → Net Mode → Play As Client, кількість гравців ≥ 2.

Консольні команди працюють і на клієнті — вони самі пересилаються на сервер, а звіт повертається назад:

InvGive Potion 5
InvList
InvOverlay

Інспектор показує режим мережі кольоровою міткою: зелена — авторитетна сторона, жовта — клієнт. Відкрийте його на обох боках, щоб побачити, чи збігається стан.

Якщо предмет не з'явився:

Log LogInventorySystem Verbose

Друкує кожне додавання, вилучення й переміщення з індексами слотів.


Куди далі