Архітектура¶
🇬🇧 English | 🇺🇦 Українська
Ця сторінка — для тих, хто читає код: розширює плагін, вбудовує його глибоко або оцінює перед тим, як брати в проєкт.
Решта посібника відповідає на питання «як цим користуватися». Тут — «як воно влаштоване і чому саме так», разом із рішеннями, які виглядають дивно, доки не знаєш причини.
Чотири модулі¶
InventorySystem ядро: дані, реплікація, компоненти, запити
InventorySystemWorld скрині, предмети на землі, таблиці луту
InventorySystemDebug інспектор і консольні команди
InventorySystemTests автоматичні тести (лише редактор)
Ядро не залежить від Slate, UMG чи будь-чого візуального. Це не стилістична
вимога: виділений сервер не повинен лінкувати UI лише для того, щоб знати вміст
рюкзака. Єдиний модуль, якому дозволено UI, — Debug, і він повністю вимикається у
Shipping.
World відокремлено, щоб проєкт, якому потрібні лише інвентарі на персонажах (картярська гра, стратегія, крафтова оболонка без світу), не компілював акторів, які ніколи не розмістить.
Головна ідея: маршрутизація авторитету¶
Кожен метод, що змінює стан, публічний, називається Try* і виглядає так:
bool UISInventoryComponent::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 — Мультиплеєр.
Спільна база контейнерів¶
Предмети зберігають два компоненти: UISInventoryComponent (слоти за номерами) і
UISEquipmentComponent (слоти за тегами). Різниця між ними — адресація слота, і
майже нічого більше. Спільне винесено в UISItemContainerComponent:
| Що | Навіщо саме там |
|---|---|
HasContainerAuthority() |
одна перевірка влади замість двох копій |
CanAcceptRequestFrom() |
ворота запиту, однакові для обох |
RemoveHeldInstance() |
«витрать мене» — предмет просить того, хто його тримає |
NotifyItemInstanceChanged() |
«мої дані змінилися» — позначити слот до реплікації |
| реєстрація підоб'єктів | однакова для обох, легко забути в одному |
Це не косметика. Ключове наслідок: UISItemInstance::OwningContainer вказує саме на
базу, а не на інвентар. Якби він знав лише інвентар, то:
- статистика, записана у вдягнений предмет, не повідомляла б нікого — слот не позначався б до реплікації, і жодна панель не перемальовувалась би;
- фрагмент, що хоче витратити предмет, мусив би шукати його індекс слота в інвентарі; у вдягненого предмета такого індексу немає, тож випита вдягнена фляга оголошувала б ефект і нічого не витрачала.
Обидві властивості належать «тому, хто тримає предмет», тому й живуть в одному місці.
Фрагменти як механізм розширення¶
UISItemDefinition не має власної поведінки — лише список UISItemFragment.
Готові п'ять (Stackable, Equippable, Consumable, Durability, Weight) — це
звичайні підкласи, нічим не привілейованіші за ваші.
Правило, яке визначає все інше¶
Об'єкт фрагмента живе всередині ассета і спільний для кожного екземпляра
предмета в грі. Сто смолоскипів у ста гравців вказують на один об'єкт
UISFragment_Durability.
Тому:
- усі хуки оголошені
const; - запис у поле фрагмента під час гри редагує ассет для всіх одразу;
- змінний стан належить
UISItemInstance::StatValues.
Порушення цього правила псує дані глобально й непомітно — це найважливіший інваріант кодової бази.
Як ядро лишається незалежним¶
Ядро не включає жодного конкретного фрагмента. Похідні значення рахуються ланцюжком:
int32 Value = 1; // початкове
for (Fragment : Fragments)
Value = Fragment->ModifyMaxStackSize(Value); // кожен може змінити
Саме тому предмет без Stackable має стек 1 — початкове значення просто ніхто не
змінив. Додати новий вид похідного значення = додати хук, не чіпаючи компоненти.
Детермінованість¶
Хуки-запити (CanStackWith, Modify*) виконуються і на клієнтах. Недетермінований
хук дасть розсинхронізований інвентар. Хуки, що змінюють стан, — лише на сервері.
Реплікація¶
FastArraySerializer¶
FISInventoryList і FISEquipmentList надсилають дельти: змінився один слот —
пішов один слот. Різниця між 40 байтами й кількома кілобайтами, коли гравець підняв
камінець.
Два шляхи підоб'єктів — і чому жоден не нав'язується¶
Предмети (UISItemInstance) — реплікований UObject. UE має два механізми: класичний
ReplicateSubobjects і реєстр підоб'єктів.
Плагін підтримує обидва і слідує тому, який обрав проєкт. Примусово вмикати реєстр не можна: він працює лише коли актор-власник теж його увімкнув, а рушійний дефолт — вимкнено. Компонент із примусовим реєстром на «звичайному» акторі тихо перестав би реплікувати предмети зовсім.
Пастка з покажчиками на записи¶
TArray::RemoveAt зсуває елементи. Покажчик FISInventoryEntry*, узятий до мутації,
після неї вказує на інший запис — або за межі масиву.
Тому уся мутація працює з індексами слотів, а не з покажчиками, і на це є тест
(InventorySystem.Inventory.Swap.Regression.MergeDoesNotCorrupt). Якщо ви розширюєте плагін —
це найлегше правило порушити ненавмисно.
Момент присвоєння власника¶
FISInventoryList::OwnerComponent присвоюється в конструкторі компонента, а не в
BeginPlay. Клієнт може отримати першу дельту раніше за BeginPlay, і з порожнім
власником коллбеки FastArray мовчки загубили б найперше оновлення — класичний баг
«UI порожній, доки не підбереш другий предмет».
Слоти розріджені¶
Індекси йдуть 0..MaxSlots-1, але всередині існують лише зайняті записи. Інвентар
на 30 слотів із трьома предметами зберігає три записи з індексами, скажімо, 0, 7 і 29.
Наслідок для коду: GetAllEntries()[N] — це не слот N. Індекс береться з самого
запису (Entry.SlotIndex).
MaxSlots = 0 означає безлімітний інвентар. Окремого прапорця немає навмисно: два
незалежні поля («скільки слотів» і «чи безлімітно») неминуче суперечать одне одному,
щойно хтось змінить одне й забуде друге.
Карта коду¶
| Файл | Що читати |
|---|---|
Core/ISInventoryComponent.h |
коментар класу описує маршрутизацію авторитету — вона повторюється всюди |
Core/ISItemContainerComponent.h |
що спільного в інвентаря й спорядження, і ворота запиту |
Core/ISItemFragment.h |
правило спільності фрагментів і всі 12 хуків |
Core/ISItemInstance.h |
що робить копію унікальною, правила стакування |
Core/ISInventoryList.h |
чому мутація йде через методи, а не через покажчики |
Core/ISInventorySettings.h |
що взагалі налаштовується проєктом |
Fragments/ISFragment_Durability.cpp |
найповніший приклад самодостатнього фрагмента |
InventorySystemTests/Private/Tests/ISRegressionTests.cpp |
гарантії, які найлегше зламати ненавмисно, з поясненням чому |
Коментарі в заголовках навмисно розгорнуті: тултипи, які бачить дизайнер у редакторі, і пояснення для програміста — це той самий текст.
Свідомі обмеження¶
Речі, яких плагін не робить, щоб ви не шукали їх у коді:
Немає готового UI. Плагін дає дані та події. Жодні дві гри не малюють інвентар однаково, тож вбудований віджет довелося б обходити.
Немає системи ефектів. Consumable повідомляє теги спожитого; що означає
«зцілити на 50» — вирішує гра. Це точка інтеграції з GAS або власною системою.
Немає сітки з формами предметів. Слот однорідний, предмет займає рівно один. «Тетрісний» інвентар не підтримується.
Немає вбудованої взаємодії. Контейнери й пікапи не мають меша та колізії: гра з тригером, гра з трасою й гра з кліком хочуть різного.
Інвентар не приватний. Вміст реплікується всім спостерігачам актора-власника — наслідок того, що один клас обслуговує і рюкзак гравця, і скриню, вміст якої мають бачити всі. Читати чужий інвентар клієнт може; змінювати — ні, це закривають ворота запиту.
Кулдаун Consumable один на світ. Він спільний для всіх гравців, тож для
змагальної гри не годиться. Це свідомо простий інструмент; кулдауни на гравця
будуються поверх події OnConsumableUsed.
Пошук слота лінійний. FISInventoryList шукає слот перебором записів. Для
рюкзака на 30–40 слотів це швидше за будь-який індекс; для безлімітного контейнера на
десятки тисяч предметів — ні. Сортування, ущільнення й облік ваги при цьому лінійні
за розміром інвентаря; квадратичним лишається тільки сам перебір, і це свідомо.
Якщо ви змінюєте код плагіна¶
Звичайна збірка проєкту компілює редакторський таргет, де багато заголовків
приходить транзитивно. Пакування компілює UnrealGame, де їх немає — тому плагін,
який чисто збирається в редакторі, може не спакуватися через єдиний відсутній
#include.
Якщо ви додали заголовок, що покладається на транзитивні включення, перевірте пакуванням:
RunUAT.sh BuildPlugin -Plugin="<шлях>/InventorySystem.uplugin" \
-Package=<тимчасова тека> -TargetPlatforms=Mac -Rocket
Помилки вилазять по одній за прогін — очікуйте кілька кіл.
Куди далі¶
- Модель даних простою мовою: 01 — Основні поняття
- Написати власний фрагмент: 04 — Фрагменти
- Готові сценарії: 11 — Рецепти