Skip to content

Архітектура

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

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

Решта посібника відповідає на питання «як цим користуватися». Тут — «як воно влаштоване і чому саме так», разом із рішеннями, які виглядають дивно, доки не знаєш причини.


Три модулі

InventorySystemDemo       ядро: дані, реплікація, компоненти, запити
InventorySystemDemoWorld  скрині, предмети на землі, таблиці луту
InventorySystemDemoDebug  інспектор і консольні команди

Ядро не залежить від Slate, UMG чи будь-чого візуального. Це не стилістична вимога: виділений сервер не повинен лінкувати UI лише для того, щоб знати вміст рюкзака. Єдиний модуль, якому дозволено UI, — Debug, і він повністю вимикається у Shipping.

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


Головна ідея: маршрутизація авторитету

Кожен метод, що змінює стан, публічний, називається Try* і виглядає так:

bool UISDemoInventoryComponent::TryAddItem(...)
{
    if (!HasContainerAuthority())
    {
        Server_AddItem(Definition, Count);   // клієнт -> сервер
        return true;                          // "запит надіслано"
    }
    return AddItem_Internal(...);             // маємо владу - працюємо тут
}

Server_* RPC та *_Internal реалізації — protected. Вони не є частиною контракту: користувач плагіна їх не бачить і не обирає між ними.

Чому не два методи

Звична альтернатива — пара методів: «надіслати запит» і «зробити авторитетно». Вона змушує кожного, хто пише виклик, пам'ятати, де саме той виконується, і карає за помилку мовчки: авторитетний метод, позначений BlueprintAuthorityOnly, викликаний із клієнтського UI-віджета, просто нічого не робить — без помилки й без попередження.

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

Ціна рішення

На клієнті Try* повертає true у значенні «запит надіслано», а не «виконано» — синхронно знати результат неможливо. Тому UI будують на подіях. Це задокументовано в кожному тултипі й у 09 — Мультиплеєр.

Єдиний виняток

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

Ворота запиту

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

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

Деталі й приклади — у 09 — Мультиплеєр.


Спільна база контейнерів

Предмети зберігають два компоненти: UISDemoInventoryComponent (слоти за номерами) і UISDemoEquipmentComponent (слоти за тегами). Різниця між ними — адресація слота, і майже нічого більше. Спільне винесено в UISDemoItemContainerComponent:

Що Навіщо саме там
HasContainerAuthority() одна перевірка влади замість двох копій
CanAcceptRequestFrom() ворота запиту, однакові для обох
RemoveHeldInstance() «витрать мене» — предмет просить того, хто його тримає
NotifyItemInstanceChanged() «мої дані змінилися» — позначити слот до реплікації
реєстрація підоб'єктів однакова для обох, легко забути в одному

Це не косметика. Ключове наслідок: UISDemoItemInstance::OwningContainer вказує саме на базу, а не на інвентар. Якби він знав лише інвентар, то:

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

Обидві властивості належать «тому, хто тримає предмет», тому й живуть в одному місці.


Фрагменти як механізм розширення

UISDemoItemDefinition не має власної поведінки — лише список UISDemoItemFragment. Готові п'ять (Stackable, Equippable, Consumable, Durability, Weight) — це звичайні підкласи, нічим не привілейованіші за ваші.

Правило, яке визначає все інше

Об'єкт фрагмента живе всередині ассета і спільний для кожного екземпляра предмета в грі. Сто смолоскипів у ста гравців вказують на один об'єкт UISDemoFragment_Durability.

Тому:

  • усі хуки оголошені const;
  • запис у поле фрагмента під час гри редагує ассет для всіх одразу;
  • змінний стан належить UISDemoItemInstance::StatValues.

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

Як ядро лишається незалежним

Ядро не включає жодного конкретного фрагмента. Похідні значення рахуються ланцюжком:

int32 Value = 1;                                   // початкове
for (Fragment : Fragments)
    Value = Fragment->ModifyMaxStackSize(Value);   // кожен може змінити

Саме тому предмет без Stackable має стек 1 — початкове значення просто ніхто не змінив. Додати новий вид похідного значення = додати хук, не чіпаючи компоненти.

Детермінованість

Хуки-запити (CanStackWith, Modify*) виконуються і на клієнтах. Недетермінований хук дасть розсинхронізований інвентар. Хуки, що змінюють стан, — лише на сервері.


Реплікація

FastArraySerializer

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

Два шляхи підоб'єктів — і чому жоден не нав'язується

Предмети (UISDemoItemInstance) — реплікований UObject. UE має два механізми: класичний ReplicateSubobjects і реєстр підоб'єктів.

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

Пастка з покажчиками на записи

TArray::RemoveAt зсуває елементи. Покажчик FISDemoInventoryEntry*, узятий до мутації, після неї вказує на інший запис — або за межі масиву.

Тому уся мутація працює з індексами слотів, а не з покажчиками, і на це є тест (InventorySystemDemo.Inventory.Swap.Regression.MergeDoesNotCorrupt). Якщо ви розширюєте плагін — це найлегше правило порушити ненавмисно.

Момент присвоєння власника

FISDemoInventoryList::OwnerComponent присвоюється в конструкторі компонента, а не в BeginPlay. Клієнт може отримати першу дельту раніше за BeginPlay, і з порожнім власником коллбеки FastArray мовчки загубили б найперше оновлення — класичний баг «UI порожній, доки не підбереш другий предмет».


Слоти розріджені

Індекси йдуть 0..MaxSlots-1, але всередині існують лише зайняті записи. Інвентар на 30 слотів із трьома предметами зберігає три записи з індексами, скажімо, 0, 7 і 29.

Наслідок для коду: GetAllEntries()[N] — це не слот N. Індекс береться з самого запису (Entry.SlotIndex).

MaxSlots = 0 означає безлімітний інвентар. Окремого прапорця немає навмисно: два незалежні поля («скільки слотів» і «чи безлімітно») неминуче суперечать одне одному, щойно хтось змінить одне й забуде друге.


Карта коду

Файл Що читати
Core/ISDemoInventoryComponent.h коментар класу описує маршрутизацію авторитету — вона повторюється всюди
Core/ISDemoItemContainerComponent.h що спільного в інвентаря й спорядження, і ворота запиту
Core/ISDemoItemFragment.h правило спільності фрагментів і всі 12 хуків
Core/ISDemoItemInstance.h що робить копію унікальною, правила стакування
Core/ISDemoInventoryList.h чому мутація йде через методи, а не через покажчики
Core/ISDemoInventorySettings.h що взагалі налаштовується проєктом
Fragments/ISDemoFragment_Durability.cpp найповніший приклад самодостатнього фрагмента

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


Свідомі обмеження

Речі, яких плагін не робить, щоб ви не шукали їх у коді:

Немає готового UI. Плагін дає дані та події. Жодні дві гри не малюють інвентар однаково, тож вбудований віджет довелося б обходити.

Немає системи ефектів. Consumable повідомляє теги спожитого; що означає «зцілити на 50» — вирішує гра. Це точка інтеграції з GAS або власною системою.

Немає сітки з формами предметів. Слот однорідний, предмет займає рівно один. «Тетрісний» інвентар не підтримується.

Немає вбудованої взаємодії. Контейнери й пікапи не мають меша та колізії: гра з тригером, гра з трасою й гра з кліком хочуть різного.

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

Кулдаун Consumable один на світ. Він спільний для всіх гравців, тож для змагальної гри не годиться. Це свідомо простий інструмент; кулдауни на гравця будуються поверх події OnConsumableUsed.

Пошук слота лінійний. FISDemoInventoryList шукає слот перебором записів. Для рюкзака на 30–40 слотів це швидше за будь-який індекс; для безлімітного контейнера на десятки тисяч предметів — ні. Сортування, ущільнення й облік ваги при цьому лінійні за розміром інвентаря; квадратичним лишається тільки сам перебір, і це свідомо.


Якщо ви змінюєте код плагіна

Звичайна збірка проєкту компілює редакторський таргет, де багато заголовків приходить транзитивно. Пакування компілює UnrealGame, де їх немає — тому плагін, який чисто збирається в редакторі, може не спакуватися через єдиний відсутній #include.

Якщо ви додали заголовок, що покладається на транзитивні включення, перевірте пакуванням:

RunUAT.sh BuildPlugin -Plugin="<шлях>/InventorySystemDemo.uplugin" \
  -Package=<тимчасова тека> -TargetPlatforms=Mac -Rocket

Помилки вилазять по одній за прогін — очікуйте кілька кіл.


Куди далі