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
Друкує кожне додавання, вилучення й переміщення з індексами слотів.
Куди далі¶
- Зберегти стан між сесіями: 10 — Збереження
- Інструменти діагностики: 13 — Відлагодження