Куток

Обкладинка для допису Про Unreal Engine, геймпад та віджети (1)
Andreas Rudenko
Andreas Rudenko

Додано

Про Unreal Engine, геймпад та віджети (1)

В цьому лонгріді я б хотів торкнутися такої теми, як використання геймпаду в іграх на Unreal Engine. Для ілюстрацій буде використано код з проєктів на версії 4.27
Цьому саме ця тема? Бо, сівши робити підтримку геймпада, я зіштовхнувся з безліччю підводних каменів, про які до цього моменту навіть не замислювався, і найбільшого головного болю мені завдало налашнування UI для того, щоб воно сприймало команди з геймпаду. Тож цей допис спрямований саме на те, щоб розповісти про мій опит приборкання геймпаду в Unreal Engine

Частина перша: Навчаємо гру знати, який контролер використовується.

Особисто я, коли граю в ігри, часто переходжу з одного типу контролера на інший - наприклад мені дуже подобається керувати транспортом через геймпад, але брати участь у стрілянині набагато зручніше використовуючи клавіатуру та мишу. Тобто ситуація, коли гравець під час гри відкладає клавіатуру і бере в руки геймпад є абсолютною нормою, і перше що нам потрібно зробити - це систему, яка буде відстежувати та надсилати іншим частинам гри сповіщення кожного разу, коли гравець змінює тип контролера.
Почнемо з того, що за ввод відповідає PlayerController, тому буде логічно що саме він міститиме в собі наш код, до того ж *PlayerController *доступен з будь-якого місяця гри.
Спочатку придумаємо як саме ми зберігатимемо назву поточного типу вводу. В принципі це можна зробити, додавши змінну типу String і домовитись, що ввід з клавіатури буде позначатись як "Keyboard", а з геймпаду як "Gamepad". В моєму випадку я створю enum з потрібними мені значеннями:

UENUM(BlueprintType)
enum class EUserInputScheme : uint8 
{
    UIS_Keyboard    UMETA(DisplayName = "MouseKeyboard"),
    UIS_Gamepad     UMETA(DisplayName = "Gamepad"),
    UIS_VR          UMETA(DisplayName = "VR"),
};
Увімкніть повноекранний режим Вийти з повноекранного режиму

Або в блюпринтах
Створюємо потрібний нам enum

Після чого почнемо робити нашу систему.
Основна ідея це здатність PlayerController відстежувати натискання кнопки на будь-якому девайсі, та вміння розрізняти до якого типу цей девайс належить. Тому наш алгоритм виглядатиме наступним чином:

  • Знайти що було натиснуто
  • Розповсюдити інформацію про натиснуту кнопку (нам це знадобиться згодом)
  • Перевірити до якого типу належить ця кнопка (кнопка миші, клавіатури, геймпада тощо)
  • Якщо треба - запам'ятати цей тип та розповсюдити цю інформацію.

В блюпрінтах це виглядатиме приблизно наступним чином:

Опис картинки
Опис картинки

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

В с++ це робити не так зручно. Головна різниця в тому, що замість івенту AnyKey ми формуємо список усіх кнопок, а потім кожен тік перевіряємо, чи було кнопку з цього списку натиснуто, використовуючи функцію WasInputKeyJustPressed(). Після чого алгоритм той же самий що і у випадку блюпрінтів.

void AMyPlayerController::Tick(float DeltaSeconds)
{
    Super::Tick(DeltaSeconds);
    CheckKeyPressed();
}

void AMyPlayerController::CheckKeyPressed()
{
    static TArray<FKey> keys;
    EKeys::GetAllKeys(keys);

    for (const auto& key : keys)
    {
        if (WasInputKeyJustPressed(key))
        {
            LastKeyPressed = key;
            OnAnyKeyPressed.Broadcast(LastKeyPressed);
            CheckInputKey();
            return;
        }
    }   
}

void AMyPlayerController::CheckInputKey()
{
    const bool bNewKeyIsKeyboard{ UKismetInputLibrary::Key_IsKeyboardKey(LastKeyPressed) };
    const bool bNewKeyIsMouse{ UKismetInputLibrary::Key_IsMouseButton(LastKeyPressed) };

    if ((bNewKeyIsKeyboard || bNewKeyIsMouse) && (InputScheme != EUserInputScheme::UIS_Keyboard))
    {
        SetInputScheme(EUserInputScheme::UIS_Keyboard);
        return;
    }

    const bool bNewKeyIsGamepad{ UKismetInputLibrary::Key_IsGamepadKey(LastKeyPressed) };
    if (bNewKeyIsGamepad && (InputScheme != EUserInputScheme::UIS_Gamepad))
    {
        SetInputScheme(EUserInputScheme::UIS_Gamepad);
    }
}
Увімкніть повноекранний режим Вийти з повноекранного режиму

А що робити з мишкою?

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

  • Якщо використовується клавіатура, то ми можемо на паритись через відстежування миші, бо вона і так належить до клавіатурного вводу
  • Якщо використовується геймпад - то нам буде потрібна функція CheckMouseMovement(), яка відстежує чи поворухнув гравець мишкою, і якщо він це зробив - перемикає ввод на клавіатуру.
  • Для того, щоб визначити ворухнулась миша чи ні можна використовувати її координати, але в майбутньому, коли ми будемо рухати мишкою за допомогою геймпаду (так роблять багато ігор де є маніпуляції з інвентарем, наприклад Green Hell та The Forest) це призведе до хибних перемикань на клавіатуру - тож замість координат ми будемо порівнювати не координати, а довжину вектора миші, отриману з координат. В майбутньому це дасть нам можливість вручну маніпулювати цим числом і не дозволяти перемикатись на клавіатуру, навіть коли курсор миші змінює своє положення - але до цього нам ще далеко :)

Виглядатиме наш код наступним чином:

void AMyPlayerController::CheckMouseMovement()
{
    MouseCurrent = GetMouseVectorLength();
    if (FMath::Abs(MouseCurrent - MousePrevious) >= MOUSE_INPUT_TRESHOLD)
    {
        SetInputScheme(EUserInputScheme::UIS_Keyboard);
    }
    MousePrevious = MouseCurrent;
}

float AMyPlayerController::GetMouseVectorLength() const
{
    float mouseX, mouseY;
    GetMousePosition(mouseX, mouseY);
    return UKismetMathLibrary::VSize2D(FVector2D(mouseX,mouseY));
}
Увімкніть повноекранний режим Вийти з повноекранного режиму

Або в блюпрінтах

Опис картинки

Перший підводний камінь - SetInputScheme

Здавалося б що функція, для зберігання та розповсюдження типу вводу має бути чимось простим і незамислуватим, щось назразок

InputScheme = NewScheme;
OnInputSchemeChanged.Broadcast(NewScheme);
Увімкніть повноекранний режим Вийти з повноекранного режиму

І цей кусок коду дійсно простий, але є нюанс, і цей нюанс - мишка! Що потрібно зробити з курсором мишки, якщо ми перемикаємось з клавіатури на геймпад - звісно приховати і це зрозуміло. А що потрібно зробити, якщо переходимо з геймпада на клавіатуру? Еммм... можливо показати мишку, якщо у нас відкрито вікно діалогу або інвентарю, а можливо не показувати, якщо ми посеред спекотної перестрілки або швидкісного переслідування. Тож що робити?
Першою думкою є зробити перевірку, чи є у нас на екрані елемент UI який потребує миші - тобто діалогові кнопки або кнопки предметів в інвентарі. Якщо є - то ми знаємо що нам знадобиться курсор, якщо ж ні - значить курсор має бути прихований. Але як це зробити...
І тут Unreal Engine підкладає нам першу серйозну перешкоду, бо у нас немає стандартного методу, щоб з'ясувати видимість віджету. Стандартний IsVisible() занадто тупий і не бере до уваги ієрархію віджетів, тож нам доведеться все робити самим.
Наш алгоритм буде таким:

  • Ми будемо перевіряти усі віджети типу UUserWidget на те, чи містять вони видимі віджети UButton - так як вони лежать у основі усіх елементів UI, які вимагають натискання мишкою.
  • Якщо ми знайшли хоча б одну видиму кнопку - значить нам потрібна мишка! Якщо не знайшли жодної - то і мишка непотрібна.
bool AMy_PlayerController::IsWidgetNeedsMouseOnScreen() const
{
    for (TObjectIterator<UUserWidget> Itr; Itr; ++Itr)
    {
        TArray<UWidget*> foundWidgets;
        UWidgetTree* widgetTree = Itr->WidgetTree;

        if (widgetTree)
        {
            widgetTree->GetAllWidgets(foundWidgets);
            for (UWidget* widget : foundWidgets)
            {
                UButton* button = Cast<UButton>(widget);
                if (button && IsWidgetVisible(button))
                {
                    return true;
                }
            }
        }   
    }
    return false;
}
Увімкніть повноекранний режим Вийти з повноекранного режиму

Питання як працює IsWidgetVisible() ми розглянемо згодом, щоб не відволікатись, а поки що маємо змогу закінчити наш SetInputScheme()

void AMy_PlayerController::SetInputScheme(EUserInputScheme NewScheme)
{
    InputScheme = NewScheme;

    switch (NewScheme)
    {
    case EUserInputScheme::UIS_Keyboard:
        if (IsWidgetNeedsMouseOnScreen())
        {
            bShowMouseCursor = true;
        }

        GetWorldTimerManager().PauseTimer(MouseInputHandle);
    break;
    case  EUserInputScheme::UIS_Gamepad:
        ULowEntryExtendedStandardLibrary::SetMousePosition(0,0);
        bShowMouseCursor = false;

        MousePrevious = MouseCurrent = GetMouseVectorLength();
        GetWorldTimerManager().UnPauseTimer(MouseInputHandle);  
    break;
    }

    OnInputSchemeChanged.Broadcast(NewScheme);
}
Увімкніть повноекранний режим Вийти з повноекранного режиму

Опис картинки

Також саме звідси ми керуємо нашим відстежуванням рухів миші - CheckMouseMovement() - через таймер CheckMouseTimer У випадку, коли ми переходимо на клавіатуру і відстежування не потрібно - ставимо таймер на паузу, а коли переходимо на геймпад - знову запускаємо. Важливим є перемістити мишку подалі від будь-яких важливих об'єктів UI - навіть прихована мишка все ще вміє клікати та викликати різні пов'язані з нею методи як то OnHover або OnMouseEnter що нам зовсім не потрібно.
Гадаю кожен геймер хоч раз в житті бачив баг, коли прихована мишка з'являлась в кутку екрана - так ми й зробимо, перемістивши її в початок екранних координат.
Пам'ятаєте я казав що ми використаємо довжину вектора координат, щоб рухати мишку і не перемикатися на клавіатуру? Рядок 'MousePrevious = MouseCurrent = GetMouseVectorLength();' робить саме це. Ми здвинули мишку з центру екрана в кут, і при цьому гра не вважає що ми для цього використали клавіатуру!

Нам довелось використати сторонню бібліотеку ULowEntryExtendedStandardLibrary, але в самій функції SetMousePosition немає нічого страшного:

void ULowEntryExtendedStandardLibrary::SetMousePosition(const int32 X, const int32 Y)
{
    UGameViewportClient* ViewportClient = GEngine->GameViewport;
    FViewport* Viewport = ViewportClient->Viewport;
    FIntPoint Size = Viewport->GetSizeXY();
    Viewport->SetMouse(FMath::Clamp(X, 0, Size.X), FMath::Clamp(Y, 0, Size.Y));
}
Увімкніть повноекранний режим Вийти з повноекранного режиму

Тепер ми маємо код, який відслідковує натиснення кнопок та рухи мишкою, вміє розрізняти клавіатуру та геймпад, та приховувати або відображати курсор мишки. Перший крок зроблено!

Розділ другий: про видимість віджетів

А тепер ми трохи детальніше розглянемо одну з великих проблем, пов'язаних із віджетами - як з'ясувати видимий він чи ні?
Деякі віджети, які гравець викликає відносно часто (наприклад інвентар чи карта) не має сенсу знищувати і створювати кожен раз, коли він потрібен - набагато ефективніше приховати, а потім знову відобразити.
Звертаючись до торгівця немає сенсу кожного разу створювати з нуля відповідні саб-віджети з його товарами - достатньо зробити це один раз, а наступні рази лише робити вже наявний віджет видимим. Але це накладає на нас обмеження - прихований віджет має бути "неактивним", він повинен ігнорувати натискання будь-яких кнопок, не робити жодних обчислень.
Як ми скоро побачимо, проблема виникає коли віджет наслідує видимість своїх батьків. Уявіть звичайне ігрове меню, яке має дві кнопки - "Так" та "Ні", кнопки видимі, але саме меню приховано до тих пір, доки воно не знадобиться. Який результат нам дасть стандартний метод IsVisible() стосовно цих кнопок? Зробимо невеликий тест

Опис картинки

Опис картинки

Маємо одну звичайну кнопку Button, яка знаходиться всередині віджета Overlay та два текстових віджета, які казатимуть нам прихована кнопка чи ні. Видимість кнопки встановлена у Visible, а видимість Overlay - у Hidden.
І що ми бачимо?

Опис картинки
Кнопка прихована, але система вважає що вона видима! Насправді це досить погана ситуація, бо якби ми прив'язали, наприклад, "А" геймпаду до цієї кнопки - ми могли б її "натиснути" навіть коли кнопка прихована. Баг, і потенційно небезпечний! Ви ж не хочете вийти з гри, бо натиснули невидиму кнопку "Вихід"?
Те ж саме відбулося б з нашою функцією SetInputScheme, якби ми використали "тупий" IsVisible() - система б вирішила що у нас є на екрані кнопка - і зробила б мишку видимою, хоча насправді нам це зовсім не потрібно.
Проведемо ще один експеримент. Цього разу і Button і Overlay будуть встановлені у Visible, але сам головний віджет буде приховано і додано до іншого віджету.

Опис картинки
Результат перевірки Button **на **IsVisible() той самий - кнопка прихована, але Unreal Engine так не вважає.

Опис картинки

Якщо ми подивимось як працює IsVisible() то зрозуміємо чому це так працює:

/**
     * @return is this widget visible, hidden or collapsed.
     * @note this widget can be visible but if a parent is hidden or collapsed, it would not show on screen. */
    FORCEINLINE EVisibility GetVisibility() const { return Visibility.Get(); }

bool UWidget::IsVisible() const
{
    TSharedPtr<SWidget> SafeWidget = GetCachedWidget();
    if ( SafeWidget.IsValid() )
    {
        return SafeWidget->GetVisibility().IsVisible();
    }

    return false;
}

bool IsVisible() const
    {
        return 0 != (Value & VIS_Visible);
    }

/** Default widget visibility - visible and can interactive with the cursor */
        VIS_Visible = VISPRIVATE_Visible | VISPRIVATE_SelfHitTestVisible | VISPRIVATE_ChildrenHitTestVisible,

Увімкніть повноекранний режим Вийти з повноекранного режиму

Метод IsVisible() отримує значення Visibility (нагадую що це одне зі значень: Visible, Hidden, Collapsed, Not Hit Testable) та повертає true якщо це значення Visible або Not Hit Testable. Нам навіть нагадують:

this widget can be visible but if a parent is hidden or collapsed, it would not show on screen.

Але ж нам саме це і потрібно знати! Що ж робити?
Доведеться пірнати у код і писати його самому (насправді не зовсім, бо більшість роботи за нас вже зробив Rama у Victory plugin)

IsWidgetVisible() or not?

Тож як ми дізнаємось СПРАВЖНЮ видимість віджета?
По-перше, треба пройти вгору по всьому ланцюгу наслідування. Якщо хоча б один з батьків невидимий - то і наш віджет теж невидимий

UWidget* Parent = Widget->GetParent();
        while (Parent)
        {
            if (!Parent->IsVisible())
            {
                return false;
            }
            Parent = Parent->GetParent();
        }
Увімкніть повноекранний режим Вийти з повноекранного режиму

У нашому першому прикладі цього б вистачило. Цей код достатньо розумний, щоб визначити що Overlay невидимий, а отже наша кнопка теж. Але у другому прикладі цей код все одно повернув би true. Чому? Бо він не вміє "перестрибувати" з одного UserWidget на інший, тобто перевірка зупинилась би на ланцюгу Button->Overlay->Canvas і ніколи б не дійшла до NewUserWidget. Для того, щоб "перестрибувати" між вложеними *UserWidget * нам потрібен додатковий цикл, який буде перевіряти інший ланцюжок наслідування:

UObject* outer = Widget->GetOuter();
        while (outer)
        {
            UUserWidget* outerWidget{ Cast<UUserWidget>(outer) };

            if (outerWidget)
            {
                if (!outerWidget->IsVisible())
                {
                    return false;
                }
            }
            outer = outer->GetOuter();
        }
return true;

Увімкніть повноекранний режим Вийти з повноекранного режиму

Тут ми рухаємось ланцюжком Outer, у випадку другого приклада він матиме вид Button->WidgetTree->MyWidget_0->WidgetTree->MyWidget_1->GameInstance->NULL
Об'єкти WidgetTree нам не цікаві, але ми отримали доступ до ланцюжка UserWidget і тепер можемо перевірити їх видимість.

Опис картинки

Опис картинки

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

Розділ третій: press X to win

Нарешті ми наближаємось до теми допису - як прив'язати кнопки геймпада до функціонала UI? Припустимо що ми зробили меню з трьох кнопок, за допомогою якого можна почати нову гру, завантажити сейв або вийти з гри. І ми хочем, щоб за ці дії відповідали кнопки геймпада "A", "B", "X".
Наш віджет "слухає" натискання, які передає MyPlayerController , і щойно знаходить потрібне - виконує пов'язану з цією кнопкою дію. При чому ми можемо бути певні, що? якщо кнопка "Завантажити гру" прихована (наприклад ми її сховали, тому що гравець ще жодного разу не зберігав гру), то навіть натиснувши на "B" нічого не відбудеться.

Опис картинки

Опис картинки

А що робити, якщо у нас на екрані є декілька віджетів, які чекають на кнопку

Уявіть, що ви знаходитесь в меню і натискаєте "B" щоб завантажити останній сейв. Але гра показує попередження, що ви втратите весь незбережений прогрес, і пропонує натиснути "А" якщо ви з цим згодні та "В" щоб повернутись у меню

Опис картинки
Що трапиться коли гравець натисне "А" - він розпочне нову гру чи продовжить завантаження сейву, адже обидва віджети чекають на введення?
Одним методом розв'язання цієї проблеми може бути прапорець bCanRecieveInput, який ми встановлюємо у false як тільки натискаємо будь-яку з кнопок головного меню. Таким чином що б ми після цього не робили - меню не буде реагувати. Але цей метод не без недоліків - а що, якщо другий віджет може з'явитись сам по собі, наприклад його викличе мережева підсистема яка сповістить нас про великий пінг або проблеми з доступом до ігрового сервера (прямо як у Death Stranding)? Тоді у нас не буде можливості встановити значення прапорця і оба віджети будуть активні.

Опис картинки

Нам потрібен метод який буде перевіряти, які віджети знаходяться у нас на екрані, і блокувати меню якщо знайдено віджет із більшим пріоритетом. Так ми будемо впевнені, що наше меню активне лише тоді, коли це дозволено.
Опис картинки

Метод IsAnyWidgetOfClassInViewport() схожий за своїм принципом на IsWidgetVisible(), але простіший, адже має працювати лише з "батьками", тож ми не використовуємо жодних ланцюжків наслідування. Він продивляється усі UserWidget і повертає true, якщо знаходить хоча б один із списку. Нас цікавлять лише "батьківські" віджети, тому ми можемо собі дозволити використати звичайний IsVisible()

bool IsAnyWidgetOfClassInViewport(UObject* WorldContextObject, TArray <TSubclassOf<UUserWidget>> WidgetClasses)
{
    for (TObjectIterator<UUserWidget> Itr; Itr; ++Itr)
    {
        for (TSubclassOf<UUserWidget> Class : WidgetClasses)
        {
            if (Itr->IsA(Class))
            {
                if (Itr->IsVisible() && Itr->IsInViewport())
                {
                    return true;
                }
            }
        }
    }
    return false;
}
Увімкніть повноекранний режим Вийти з повноекранного режиму

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

Топ коментарі (0)

Куток

Підписуйтеся на наші соціальні мережі:
Telegram
Twitter
Facebook


Тепер у нас також є Youtube канал!