Гайд Lyra Inventory Fix 05: Ограничения инвентаря. Логика стопок и "сколько вешать в граммах?"

Какие дальнейшие фиксы или улучшения инвентаря Lyra вы бы хотели?

  • Износ и прочность: поддержка разрушаемых предметов (оружие, броня)

    Голосов: 0 0.0%

  • Всего проголосовало
    2
Начинающий
Статус
Оффлайн
Регистрация
9 Июл 2023
Сообщения
9
Реакции[?]
11
Поинты[?]
11K
Хорошо, юные (или не очень) коллеги-кудесники кода, если вы до сих пор следите за моими метаниями по просторам системы инвентаря Lyra, то поздравляю: мы только что добрались до конца начала середины первой половины разработки улучшений нашего универсального инвентаря на базе прототипы Lyra. Как видите, система еще не особо вылизана — тут и следы старых TODO, и куски прототипов, и баги с душой программиста "на авось". Но это всё поправимо, не переживайте — такого даже у Толкина не было, так что идём в верном направлении.

Итак, прежде чем мы бросимся в омут продвинутого функционала с головой, разберемся с одной вещью, без которой все ваши бездонные карманы рискуют лопнуть: система ограничений инвентаря. Вес? Количество ячеек? Всё сразу? Почему бы и нет. Ограничения — это как диета: без них быстро набирается лишний хлам в карманах, и разработчику становится стыдно за свой бардак.

Пора разгребать старый добрый TODO в LyraInventoryItemDefinition.cppTODO: Add support for stack limit / uniqueness checks / etc... и превратить его в работающий функционал . Как говорится, меньше боли, меньше слёз, больше модульности — как и положено старым кодерам. Если уж настраивать ограничения, так чтобы и на века, и расширяемо, а не на "лишь бы не сломалось". Так что погнали!

В своих потугах использую UE5.5.0.

1. Ограничим «неограниченное»

1.1 Добавляем MaxStackSize и Weight

Помните, что говорил один довольно умный и в меру нетрезвый человек? «Не впихивай невпихуемое!». Так вот, пора вписать её в ваш код. Ну что ж, начнем с двух фундаментальных ограничителей: MaxStackSize и Weight. Оба — базовые свойства, значит им самое место в ULyraInventoryItemDefinition, чтобы ни один предмет не посмел стать безразмерным.

MaxStackSize — наш главный арбитр, решающий судьбу предметов:
  • MaxStackSize = 0 — стопка бесконечна. Поздравляю, у вас есть волшебный инвентарь, куда поместится и гора лопат, и сундук с кирпичами.
  • MaxStackSize = 1 — никаких хитростей, предмет не стакается — каждый экземпляр идет в отдельный слот. Ручная граната не терпит соседей.
  • MaxStackSize > 1 — предметы собираются в стеки до указанного предела. Например, пачка стрел или бутылок с зельями.
Weight — сколько предмет весит. Пара камней в кармане может и не убьет никого, но мешок таких «камешков» уже запросто.

Вот наш кусочек добра:

LyraInventoryItemDefinition.h:
    // Maximum number of items that can be stacked together
    // 0 means unlimited stack size
    // 1 means items cannot be stacked (each item will be in its own stack)
    // >1 means items can be stacked up to this limit
    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category=Display, meta=(ClampMin="0"))
    int32 MaxStackSize = 1; // Лимит по умолчанию — не стакается

    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category=Display, meta=(ClampMin="0"))
    float Weight = 0.0f; // По умолчанию весим как пушинка

1.2 Добавляем ограничения для инвентаря

Теперь перейдем от лопат к карманам. В компоненте ULyraInventoryManagerComponent добавим ограничения на сам инвентарь:
  • MaxInventorySlots — максимальное количество слотов.
  • MaxInventoryWeight — максимальный вес, который игрок может унести. Можно, конечно, тащить тонну хлама, но пусть это будет ограничено не только моралью, но и цифрами.
Эти параметры понадобятся для проверки, сколько ещё можно впихнуть.

LyraInventoryManagerComponent.h:
    // Maximum number of slots in the inventory (0 = unlimited)
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Replicated, Category="Inventory Limits")
    int32 MaxInventorySlots = 0; // Бездонный карман по умолчанию

    // Maximum total weight that can be carried (0 = unlimited)
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Replicated, Category="Inventory Limits")
    float MaxInventoryWeight = 0.0f; // Тонны добра без ограничений

    // Returns how many items can be added to the inventory
    UFUNCTION(BlueprintCallable, Category=Inventory)
    int32 GetMaxItemsCanAdd(TSubclassOf<ULyraInventoryItemDefinition> ItemDef, int32 RequestedCount) const;

    // Get current total weight of inventory
    UFUNCTION(BlueprintCallable, Category=Inventory)
    float GetCurrentTotalWeight() const;
Простой метод подсчета текущего веса позволит вам наконец-то перестать терпеть эти "карманы без дна".

LyraInventoryManagerComponent.cpp:
float ULyraInventoryManagerComponent::GetCurrentTotalWeight() const
{
    float TotalWeight = 0.0f;
    for (const FLyraInventoryEntry& Entry : InventoryList.Entries)
    {
        if (Entry.Instance && Entry.Instance->GetItemDef())
        {
            const float ItemWeight = Entry.Instance->GetItemDef()->GetDefaultObject<ULyraInventoryItemDefinition>()->Weight;
            TotalWeight += ItemWeight * Entry.StackCount;
        }
    }
    return TotalWeight;
}

1.3 Частичный подбор предметов

Как говорил мой дед: «Не суй в карман больше, чем можешь унести». Ведь если персонаж потеет от лопаты, ему уж точно не стоит таскать целый сарай.

Добавим метод, который возвращает максимально возможное количество предметов, которые можно еще запихнуть в карманы, учитывая оба ограничения.

LyraInventoryManagerComponent.cpp:
int32 ULyraInventoryManagerComponent::GetMaxItemsCanAdd(TSubclassOf<ULyraInventoryItemDefinition> ItemDef, int32 RequestedCount) const
{
    if (!ItemDef || RequestedCount <= 0)
    {
        return 0;
    }
    const ULyraInventoryItemDefinition* ItemCDO = ItemDef->GetDefaultObject<ULyraInventoryItemDefinition>();
    const int32 MaxStackSize = ItemCDO->MaxStackSize;
    const float ItemWeight = ItemCDO->Weight;
    // Check weight limit
    if (MaxInventoryWeight > 0.0f)
    {
        const float CurrentWeight = GetCurrentTotalWeight();
        const float RemainingWeight = MaxInventoryWeight - CurrentWeight;
        const int32 MaxByWeight = FMath::FloorToInt(RemainingWeight / ItemWeight);
        RequestedCount = FMath::Min(RequestedCount, MaxByWeight);
    }
    // If we can't add any items due to weight, return early
    if (RequestedCount <= 0)
    {
        return 0;
    }
    // Get current stacks of this item
    TArray<FLyraInventoryEntry*> ExistingStacks;
    int32 CurrentSlotCount = 0;
    for (auto Entry : InventoryList.Entries)
    {
        CurrentSlotCount++;
        if (Entry.Instance && Entry.Instance->GetItemDef() == ItemDef)
        {
            ExistingStacks.Add(&Entry);
        }
    }
    int32 RemainingCount = RequestedCount;
    // Try to fit items into existing stacks first
    if (MaxStackSize != 1)  // Skip if items can't be stacked
    {
        for (FLyraInventoryEntry* Entry : ExistingStacks)
        {
            if (Entry->StackCount < MaxStackSize)
            {
                const int32 FreeSpace = MaxStackSize - Entry->StackCount;
                const int32 AmountToAdd = FMath::Min(RemainingCount, FreeSpace);
                RemainingCount -= AmountToAdd;
                if (RemainingCount <= 0)
                {
                    return RequestedCount;
                }
            }
        }
    }
    // If we still have items to add, we need new slots
    if (RemainingCount > 0)
    {
        // Check slot limit
        if (MaxInventorySlots > 0)
        {
            const int32 RemainingSlots = MaxInventorySlots - CurrentSlotCount;
            if (RemainingSlots <= 0)
            {
                return RequestedCount - RemainingCount;  // Return what we could fit in existing stacks
            }
            const int32 NeededSlots = FMath::DivideAndRoundUp(RemainingCount, (MaxStackSize > 1) ? MaxStackSize : 1);
            if (NeededSlots > RemainingSlots)
            {
                // Calculate how many items we can fit in remaining slots
                const int32 ItemsInNewStacks = RemainingSlots * ((MaxStackSize > 1) ? MaxStackSize : 1);
                return (RequestedCount - RemainingCount) + ItemsInNewStacks;
            }
        }
    }
    return RequestedCount;  // We can add all requested items
}
Здесь начинается магия: вместо того, чтобы отказывать игроку при невозможности добавить кучу предметов, даём столько, сколько можем. Это:
  • Заполняет существующие стеки до предела.
  • Использует свободные слоты для новых стеков.
  • При необходимости обрезает количество в зависимости от веса и слотов.
Таким образом, даже если инвентарь забит, частичный успех гарантирован: лучше пара стрел, чем ничего. Логично, модульно, приятно.

В функции CanAddItemDefinition(TSubclassOf<ULyraInventoryItemDefinition> ItemDef, int32 StackCount) находим нашу тодошку, удаляем ее и вместо болванки возвращаем максимально допустимое количество предметов:

LyraInventoryManagerComponent.cpp:
bool ULyraInventoryManagerComponent::CanAddItemDefinition(TSubclassOf<ULyraInventoryItemDefinition> ItemDef, int32 StackCount)
{
    return GetMaxItemsCanAdd(ItemDef, StackCount) == StackCount;
}
Далее мы беспощадно фаршируем функцию AddItemDefinition(TSubclassOf<ULyraInventoryItemDefinition> ItemDef, int32 StackCount). Для этого добавим 100 грамм логики в AddItemDefinition:
  • Двухпроходный алгоритм добавления предметов
  • Первый проход: заполняет существующие стеки до их лимита
  • Второй проход: создает новые стеки для оставшихся предметов
LyraInventoryManagerComponent.cpp:
ULyraInventoryItemInstance* ULyraInventoryManagerComponent::AddItemDefinition(TSubclassOf<ULyraInventoryItemDefinition> ItemDef, int32 StackCount)
{
    const int32 ActualStackCount = GetMaxItemsCanAdd(ItemDef, StackCount);
    if (ActualStackCount <= 0)
    {
        return nullptr;
    }

    const ULyraInventoryItemDefinition* ItemCDO = ItemDef->GetDefaultObject<ULyraInventoryItemDefinition>();
    const int32 MaxStackSize = ItemCDO->MaxStackSize;

    // If MaxStackSize is 0 or 1, use original behavior
    if (MaxStackSize <= 1)
    {
        ULyraInventoryItemInstance* Result = InventoryList.AddEntry(ItemDef, ActualStackCount);
        if (IsUsingRegisteredSubObjectList() && IsReadyForReplication() && Result)
        {
            AddReplicatedSubObject(Result);
        }
        return Result;
    }

    // Try to add to existing stacks first
    int32 RemainingCount = ActualStackCount;
    ULyraInventoryItemInstance* LastInstance = nullptr;

    // First pass: fill existing stacks
    for (FLyraInventoryEntry& Entry : InventoryList.Entries)
    {
        if (Entry.Instance && Entry.Instance->GetItemDef() == ItemDef && Entry.StackCount < MaxStackSize)
        {
            const int32 FreeSpace = MaxStackSize - Entry.StackCount;
            const int32 AmountToAdd = FMath::Min(RemainingCount, FreeSpace);
       
            Entry.StackCount += AmountToAdd;
            // Add item count as a GameplayTag so it can be retrieved from the ULyraInventoryItemInstance
            Entry.Instance->AddStatTagStack(TAG_Lyra_Inventory_Item_Count, AmountToAdd);
            RemainingCount -= AmountToAdd;
            LastInstance = Entry.Instance;

            if (RemainingCount <= 0)
            {
                break;
            }
        }
    }

    // Second pass: create new stacks if needed
    while (RemainingCount > 0)
    {
        const int32 NewStackCount = FMath::Min(RemainingCount, MaxStackSize);
        ULyraInventoryItemInstance* Result = InventoryList.AddEntry(ItemDef, NewStackCount);
        if (IsUsingRegisteredSubObjectList() && IsReadyForReplication() && Result)
        {
            AddReplicatedSubObject(Result);
        }
        LastInstance = Result;
        RemainingCount -= NewStackCount;
    }

    return LastInstance;
}

2. Плитки инвентаря: иконки и количество

Помимо железных ограничений, давайте взглянем на визуальную составляющую. У нас два недочета:
  • Предметы не имеют иконок.
  • Стек не показывает количество.

2.1 Добавляем иконки

Пора нашему хламу обзавестись иконками. Lyra использует систему фрагментов (ULyraInventoryItemFragment), что даёт отличную модульность. Это означает, что вместо того, чтобы настраивать все свойства для каждого определенного предмета инвентаря, вы выбираете, какие свойства у него есть, добавляя один или несколько ULyraInventoryItemFragment' к каждому ULyraInventoryItemDefinition.

Чтобы добавить иконку к нашему камню, нам нужно добавить один из этих фрагментов. Откройте TestID_Rock файл чертежа и добавьте новый InventoryFragment_QuickBarIcon. После этого установите для Brush любую текстуру, которая вам нравится.

Мы ещё не закончили — а вы что, думали, так просто отделаетесь? Элемент в инвентаре представлен виджетом W_InventoryTile, который, как вы, вероятно, уже заметили, выглядит абсолютно пустым. Если заглянуть внутрь, там лишь сиротливое событие OnListItemObjectSet, как забытый список покупок без единого товара. Вот это мы и будем исправлять.

OnListItemObjectSet вызывается каждый раз, когда наша плитка обновляется в своём родительском контейнере и получает объект, который ей предстоит представить. Ключ к разгадке спрятан в W_InventoryGrid. Если открыть его и заглянуть в Construct функцию, станет ясно, что он загружает каждый элемент инвентаря в CommonTileView как ULyraInventoryItemInstance. Что это значит для нас? Возвращаемся в W_InventoryTile, приводим ListItemObject к ULyraInventoryItemInstance — и вот она, зацепка. Из экземпляра предмета можно извлечь прикреплённый InventoryFragment_QuickBarIcon, который мы уже реализовали.

Если вы раньше ковырялись в Lyra, то наверняка сталкивались с FindFragmentByClass — методом старым, как программирование на C++. Эта функция, подобно опытному охотнику, находит первый фрагмент нужного класса. И нам это как раз на руку: используем её, чтобы достать QuickBarIcon, а затем устанавливаем нашему виджету кисть (или текстуру, если вы хотите звучать менее поэтично).

Когда закончите, результат должен быть таким:

5.PNG

Теперь, когда вы нажмете «Играть» и подберете камень, он наконец перестанет притворяться зелёным артефактом из закромов кода. Небольшой довод Blueprint до ума — и инвентарь заиграет новыми красками.


2.2 Отображаем количество

Никакая стопка не имеет смысла, если на ней нет циферки. В структуре FLyraInventoryEntry есть StackCount для отслеживания количества предметов в каждом стеке, но он упорно прячется от Blueprints. Добавляем поддержку количества через GameplayTag, вытаскиваем через GetStatTagStackCount и передаем в UI. Опять же, аккуратно, без лишнего вмешательства в базовую структуру. Это подобно тому, как Lyra обрабатывает количество боеприпасов.

Откройте свой LyraInventoryManagerComponent.cpp файл. В начале, чуть ниже существующего UE_DEFINE_GAMEPLAY_TAG_STATIC мы определим новый тег геймплея, который будет использоваться для представления количества стеков каждого предмета:

LyraInventoryManagerComponent.cpp:
UE_DEFINE_GAMEPLAY_TAG_STATIC(TAG_Lyra_Inventory_Item_Count, "Lyra.Inventory.Item.Count");
Внутри FLyraInventoryList::AddEntry(TSubclassOf<ULyraInventoryItemDefinition> ItemDef, int32 StackCount) функции добавим следующую строку после NewEntry. StackCount = StackCount;:

LyraInventoryManagerComponent.cpp:
// Add item count as a GameplayTag so it can be retrieved from the ULyraInventoryItemInstance
NewEntry.Instance->AddStatTagStack(TAG_Lyra_Inventory_Item_Count, StackCount);
Внутри FLyraInventoryList::AddEntry(ULyraInventoryItemInstance* Instance) функции добавим следующую строку после NewEntry. StackCount: = 1;:

LyraInventoryManagerComponent.cpp:
// Add item count as a GameplayTag so it can be retrieved from the ULyraInventoryItemInstance
NewEntry.Instance->AddStatTagStack(TAG_Lyra_Inventory_Item_Count, 1);
Но погодите, это ещё не всё! У нас пока нет текстового компонента для отображения количества предметов в стеке. Виджет W_InventoryTile как бы намекает: «Добавь текстовый блок и оставь меня в покое». Так что именно это мы и сделаем. Добавляем компонент для отображения количества и идём дальше.

Теперь возвращаемся к OnListItemObjectSet и используем наш тег Lyra.Inventory.Item.Count, который мы определили в C++. С его помощью вытаскиваем StackCount из ULyraInventoryItemInstance и обновляем текст нашего нового компонента. Магия закончена — инвентарь наконец-то будет показывать количество предметов.

Когда всё готово, результат должен выглядеть вот так:

3.PNG

Теперь, чтобы проверить это, запустите игру и подберите несколько предметов. Обратите внимание, что предметы в настоящее время не будут объединяться в одну стопку. Нам нужно установить желаемый MaxStackSize в шаблоне предмета инвентаря (ItemDefinition), прикрепленном к кубам вашего уровня.

1.PNG

И теперь каждое добавление предмета обновляет StackCount, а плитка в инвентаре показывает цифру. Визуальный порядок восстановлен: десять стрел, три зелья, две гранаты — всё как полагается.

2.PNG

4. Итоги: система готова, но не "на авось"

Эта система ограничений — не просто затычка для недоработанного инвентаря. Мы разгребли старый TODO, добавили ограничения и сделали наш инвентарь модульным и гибким. Теперь инвентарь не только знает свои пределы, но и уважает их. Он и стеки понимает, и вес считает, и даже частично добавлять умеет. Плитки стали красивыми, с иконками и количеством. Не впихивай невпихуемое, говорили они. А я говорю: «Пусть впихивает — но по правилам!»

Мораль? Когда разбираешь инвентарь — делай это с умом, чтобы не пришлось через месяц снова писать костыли. Код должен жить вечно. Старик доволен, а значит, и вам пора радоваться. Это только первый шаг к полноценной системе, но теперь она строится на века, а не "лишь бы не сломалось". Всё, что нужно, теперь можно спокойно расширить — хотите новые ограничения, условия или механики? Фундамент готов.

Ну что ж, идем дальше, коллеги. В следующий раз мы обновим ванильные функции подбора пикапов и добавим новые, чтобы в полной мере заработали наши фиксы, фильтры, ограничения и многое другое. У нас впереди ещё не одна поляна TODO — штыри в коде, как в старом саду, просто ждут, чтобы их аккуратно повыдергали. Погнали!
 
Последнее редактирование:
Сверху Снизу