반대로 이 두 케이스에 모두 해당되지 않으면 Common UI를 사용하지 않아도 무방하다.
RTS 게임
이와 별개로, WidgetComponent를 사용하여 배치된 Widget은 가급적 Common UI 사용을 지양하는 것이 좋다.
여기서 WidgetComponent라 함은 3D 월드 상에 표기되는 Widget들을 지칭한다.
캐릭터 닉네임, 체력바, 상태 등등
Common UI는 Cursor-Focus Search, Activate Order, Paint Order(LayerID)에 의존한다.
때문에 2D 게임 HUD도 처리 할 수는 있지만, Game World에 배치 된 Widget과 함께 작동하지는 않는다.
Keyboard와 Mouse 입력을 동시에 처리하는 방법
일반적으로 Common UI는 Mouse와 Keyboard의 입력을 동시에 지원하지는 않는다.
이를 동시에 지원하게 되면 UI 입장에서는 Mouse가 2개인 것처럼 느껴진다.
붕괴 스타레일은 PC에서 마우스 커서를 띄우기 위해서는 ALT 키를 누르고 있어야만 한다.
가장 간단한 방법은 입력을 Toggle로 관리하는 것이다.
보편적인 상황에서는 Keyboard Input으로 메뉴를 띄운다.
특정 키를 입력하고 있으면 Mouse Cursor가 노출된다.
이 때, 모든 키보드 입력은 막히게 된다.
Mouse Cursor가 노출되고 있는 동안에는 마우스 커서만으로 입력을 받는다.
UI 성능에 영향을 미치는 Tick이나 일시정지를 우회하는 방법
Common UI는 LocalPlayerSubsystem으로 동작하여 게임이 일시정지 하면 Tick이 되지 않는다.
Tick이 멈추면 CommonBoundActionBar를 포함한 Common UI가 동작하지 않는다.
이를 우회하려면 관련 Actor와 Widget에 tickable when paused 옵션을 활성화 해야 한다.
UI의 의도된 기능이나 퍼포먼스와 상충되지 않는지 확인 후 작업하자.
KeyHandler Method에서 Analog Input을 얻게 되는 이유
현재 InputKey/InputAxis는 PlayerController 단위에서 작업이 통합되어 있다.
이는 가짜 Input 추가를 쉽게 하고, Input Parameter를 하나로 묶어 쉽게 업데이트 하기 위함이다.
UE5 이전의 코드가 있으면 Analog Input이 Key Handler Callback을 트리거하거나, 반대로 트리거 될 수 있다.
Common UI는 이러한 기능으로부터 영향을 받지 않도록 하는 작업이 필요하다.
하지만 Input Pipeline 초기에 디버깅 중이라면, 이러한 교차 Trigger는 쉽게 발견할 수 있다.
예를 들어 FCommonAnalogCursor는 Input Processor이므로 Input Pipeline 초기 부분과 상호작용한다.
이 때문에 FCommonAnalogCursor::HandleKeyDownEvent에서 Corss Trigger 현상이 발생할 수 있다.
Input Mode가 Menu일 때 Game Input을 받을 수 있는 이유
bool UCommonUIActionRouterBase::CanProcessNormalGameInput() const
{
if (GetActiveInputMode() == ECommonInputMode::Menu)
{
// We still process normal game input in menu mode if the game viewport has mouse capture
// This allows manipulation of preview items and characters in the world while in menus.
// If this is not desired, disable viewport mouse capture in your desired input config.
const ULocalPlayer& LocalPlayer = *GetLocalPlayerChecked();
if (TSharedPtr<FSlateUser> SlateUser = FSlateApplication::Get().GetUser(GetLocalPlayerIndex()))
{
return LocalPlayer.ViewportClient && SlateUser->DoesWidgetHaveCursorCapture(LocalPlayer.ViewportClient->GetGameViewportWidget());
}
}
return true;
}
이 함수로 인해 Game Viewport에 Mouse Capture가 있으면 Menu Mode에서의 Game Input이 가능하다.
이 때문에 Menu에 있을 때 World 내 Preview Item과 Character를 조작할 수 있다.
만약 Game Input을 받고 싶지 않다면 원하는 InputMode에서 Mouse Capture를 비활성화 하면 된다.
bool FSlateApplication::ProcessKeyDownEvent( const FKeyEvent& InKeyEvent )
{
/*...*/
// Analog cursor gets first chance at the input
if (InputPreProcessors.HandleKeyDownEvent(*this, InKeyEvent))
{
return true;
}
/*...*/
}
FCommonAnalogCursor는 FAnalogCursor와 마찬가지로 IInputProcessor의 구현체이다.
FcommonAnalogCursor는 현재 Widget의 Bound Action으로 캡처되지 않은 Gamepad의 표준 Accept Action Input을 처리하지 않는다.
bool FCommonAnalogCursor::HandleKeyDownEvent(FSlateApplication& SlateApp, const FKeyEvent& InKeyEvent)
{
if (IsRelevantInput(InKeyEvent))
{
/*....*/
if (bIsVirtualAccept && ActionRouter.ProcessInput(InKeyEvent.GetKey(), InputEventType) == ERouteUIInputResult::Handled)
{
return true;
}
else if (!bIsVirtualAccept || ShouldVirtualAcceptSimulateMouseButton(InKeyEvent, IE_Pressed))
{
// There is no awareness on a mouse event of whether it's real or not, so mark that here.
UCommonInputSubsystem& InputSubsytem = ActionRouter.GetInputSubsystem();
InputSubsytem.SetIsGamepadSimulatedClick(bIsVirtualAccept);
bool bReturnValue = FAnalogCursor::HandleKeyDownEvent(SlateApp, InKeyEvent);
/*....*/
}
}
return false;
}
/** An event should return FReply::Handled().SetNavigation( NavigationType ) as a means of asking the system to attempt a navigation*/
FReply& SetNavigation(EUINavigation InNavigationType, const ENavigationGenesis InNavigationGenesis, const ENavigationSource InNavigationSource = ENavigationSource::FocusedWidget)
{
this->NavigationType = InNavigationType;
this->NavigationGenesis = InNavigationGenesis;
this->NavigationSource = InNavigationSource;
this->NavigationDestination = nullptr;
return Me();
}
Navigation Direction은 Capture되어 FReply::Handled에 포함된다.
FReply::Handled는 FReply::SetNavigation을 통해 전송된다.
void FSlateApplication::ProcessReply( const FWidgetPath& CurrentEventPath, const FReply& TheReply, const FWidgetPath* WidgetsUnderMouse, const FPointerEvent* InMouseEvent, const uint32 UserIndex )
{
/*...*/
// If we have a valid Navigation request attempt the navigation.
if (TheReply.GetNavigationDestination().IsValid() || TheReply.GetNavigationType() != EUINavigation::Invalid)
{
FWidgetPath NavigationSource;
/*...*/
if (NavigationSource.IsValid())
{
if (!GSlateEnableGamepadEditorNavigation && TheReply.GetNavigationGenesis() == ENavigationGenesis::Controller && !NavigationSource.GetLastWidget()->GetPersistentState().bIsInGameLayer)
{
// Gamepad navigation while not in a game layer, do nothing as specified by GSlateEnableGamepadEditorNavigation
}
else if (TheReply.GetNavigationDestination().IsValid())
{
const bool bAlwaysHandleNavigationAttempt = false;
ExecuteNavigation(NavigationSource, TheReply.GetNavigationDestination(), UserIndex, bAlwaysHandleNavigationAttempt);
}
else
{
TSharedRef<SWindow> NavigationWindow = NavigationSource.GetDeepestWindow();
FNavigationEvent NavigationEvent(PlatformApplication->GetModifierKeys(), UserIndex, TheReply.GetNavigationType(), TheReply.GetNavigationGenesis());
FNavigationReply NavigationReply = FNavigationReply::Escape();
for (int32 WidgetIndex = NavigationSource.Widgets.Num() - 1; WidgetIndex >= 0; --WidgetIndex)
{
FArrangedWidget& SomeWidgetGettingEvent = NavigationSource.Widgets[WidgetIndex];
if (SomeWidgetGettingEvent.Widget->IsEnabled())
{
NavigationReply = SomeWidgetGettingEvent.Widget->OnNavigation(SomeWidgetGettingEvent.Geometry, NavigationEvent).SetHandler(SomeWidgetGettingEvent.Widget);
if (NavigationReply.GetBoundaryRule() != EUINavigationRule::Escape || SomeWidgetGettingEvent.Widget == NavigationWindow || WidgetIndex == 0)
{
AttemptNavigation(NavigationSource, NavigationEvent, NavigationReply, SomeWidgetGettingEvent);
break;
}
}
}
}
}
}
/*...*/
// Set focus if requested.
TSharedPtr<SWidget> RequestedFocusRecepient = TheReply.GetUserFocusRecepient();
if (TheReply.ShouldSetUserFocus() && RequestedFocusRecepient.IsValid())
{
if (TheReply.AffectsAllUsers())
{
SetAllUserFocus(RequestedFocusRecepient, TheReply.GetFocusCause());
}
else
{
SlateUser->SetFocus(RequestedFocusRecepient.ToSharedRef(), TheReply.GetFocusCause());
}
}
}
Slate가 FSlateApplication::ProcessREply를 사용하여 Reply를 처리한다.
이 과정에서 Navigation이 발생한다.
Navigation Event가 Direction에 의해 대략 정의 된 경우, FSlateApplication::AttemptNavigation은 Navigation 할 Widget을 찾으려 한다.
Navigation이 가능한 경우, FSlateExecuteNavigation이 대상 Widget으로 Navigation한다.
대상 Widget이 유효한 경우, FSlateApplication::SetUserFocus가 호출된다.
이는 대상 Widget이 지정 되었는지, 다른 Widget을 찾았는지와 무관하게 동작한다.
void FSlateApplication::FinishedInputThisFrame()
{
const float DeltaTime = GetDeltaTime();
PlatformApplication->FinishedInputThisFrame();
// Any preprocessors are given a chance to process accumulated values (or do whatever other tick things they want)
// after we've finished processing all of the input for the frame
if (PlatformApplication->Cursor.IsValid())
{
InputPreProcessors.Tick(DeltaTime, *this, PlatformApplication->Cursor.ToSharedRef());
}
/*....*/
}
void FSlateApplication::InputPreProcessorsHelper::Tick(const float DeltaTime, FSlateApplication& SlateApp, TSharedRef<ICursor> Cursor)
{
TGuardValue<bool> IteratingGuard(bIsIteratingPreProcessors, true);
for (const TSharedPtr<IInputProcessor>& Preprocessor : InputPreProcessorList)
{
if (Preprocessor)
{
Preprocessor->Tick(DeltaTime, SlateApp, Cursor);
}
}
}
Slate Focus Navigation이 종료된 후, FSlateAnalogCursor::Tick에서 자동으로 다음 Tick 도중 Synthetic Cursor를 Focused Widget의 중앙으로 이동시킨다.
이를 통해 Gamepad에서 Hover Effect를 사용할 수 있다.
Customize Synthetic Cursor Behavior in Common UI
Common UI를 이용하면 고유한 Analogue Cursor나 Synthetic Cursor를 제공할 수 있다.
Common UI를 통해 고유 Cursor를 제공하는 것에는 몇 가지 이점이 있다.
Gamepad와 유사하게 작동하는 Keyboard Navigation을 만들려 하는 경우, Gamepad를 사용하지 않을 때에도 FCommonAnalogCursor::Tick이 Widget의 중앙에 Snap되도록 할 수 있다.
Synthetic Mouse를 보이도록 하고, Focus가 달라질 때 Tween을 구현할 수 있다.
Tween: 그래픽 상으로 두 상태나 프레임 사이를 자동으로 생성하는 Process
Custom cursor를 만드는 방법은 다음과 같다.
UCommonUIActionRouterBase를 상속받은 Custom ActionRouter Class 생성
FCommonAnalogCursor를 상속받은 Custom AnalogCursor Class 생성
Custom Router Class에서 ShouldCreateSubsystem은 Override 된 경우 Instance를 생성하지 않는다.
이러한 Input Processor를 Customize 할 때에는 한가지 주의할 점이 있다.
Input Processor는 모든 Input에서 실행 된다.
이는 Editor도 포함한다.
아래 함수를 이용하면 Application에서 발생한 Input과 그렇지 않은 것을 구분하는데 도움이 될 수 있다.
bool FCommonAnalogCursor::IsGameViewportInFocusPathWithoutCapture() const
{
if (const UGameViewportClient* ViewportClient = GetViewportClient())
{
if (TSharedPtr<SViewport> GameViewportWidget = ViewportClient->GetGameViewportWidget())
{
TSharedPtr<FSlateUser> SlateUser = FSlateApplication::Get().GetUser(GetOwnerUserIndex());
if (SlateUser && !SlateUser->DoesWidgetHaveCursorCapture(GameViewportWidget))
{
#if PLATFORM_DESKTOP
// Not captured - is it in the focus path?
return SlateUser->IsWidgetInFocusPath(GameViewportWidget);
#endif
// If we're not on desktop, focus on the viewport is irrelevant, as there aren't other windows around to care about
return true;
}
}
}
return false;
}
Input Routing
간략하게 정리하면 다음과 같다.
Common UI는 ActivableWidget을 Navigation을 처리하는 Node Tree로 구성한다.
Inactive Widget은 추가되지 않고, 그에 따라 Router 대상으로 고려되지 않는다.
이 Input Event는 현재 Viewport Interface Implemented Class로 키 누름을 전달한다.
FReply FSceneViewport::OnKeyDown( const FGeometry& InGeometry, const FKeyEvent& InKeyEvent )
{
// Start a new reply state
CurrentReplyState = FReply::Handled();
FKey Key = InKeyEvent.GetKey();
if (Key.IsValid())
{
KeyStateMap.Add(Key, true);
//@todo Slate Viewports: FWindowsViewport checks for Alt+Enter or F11 and toggles fullscreen. Unknown if fullscreen via this method will be needed for slate viewports.
if (ViewportClient && GetSizeXY() != FIntPoint::ZeroValue)
{
// Switch to the viewport clients world before processing input
FScopedConditionalWorldSwitcher WorldSwitcher(ViewportClient);
if (!ViewportClient->InputKey(FInputKeyEventArgs(this, InKeyEvent.GetInputDeviceId(), Key, InKeyEvent.IsRepeat() ? IE_Repeat : IE_Pressed, 1.0f, false)))
{
CurrentReplyState = FReply::Unhandled();
}
}
}
else
{
CurrentReplyState = FReply::Unhandled();
}
return CurrentReplyState;
}
보통은 키 누름이 전달되면 FSceneViewPort::OnKeyDown이 트리거 된다.
마지막으로 SceneViewPort는 Input을 현재 GameViewportClient의 InputKey Method로 전달한다.
bool UCommonGameViewportClient::InputKey(const FInputKeyEventArgs& InEventArgs)
{
FInputKeyEventArgs EventArgs = InEventArgs;
if (IsKeyPriorityAboveUI(EventArgs))
{
return true;
}
// Check override before UI
if (OnOverrideInputKey().IsBound())
{
if (OnOverrideInputKey().Execute(EventArgs))
{
return true;
}
}
// The input is fair game for handling - the UI gets first dibs
#if !UE_BUILD_SHIPPING
if (ViewportConsole && !ViewportConsole->ConsoleState.IsEqual(NAME_Typing) && !ViewportConsole->ConsoleState.IsEqual(NAME_Open))
#endif
{
FReply Result = FReply::Unhandled();
if (!OnRerouteInput().ExecuteIfBound(EventArgs.InputDevice, EventArgs.Key, EventArgs.Event, Result))
{
HandleRerouteInput(EventArgs.InputDevice, EventArgs.Key, EventArgs.Event, Result);
}
if (Result.IsEventHandled())
{
return true;
}
}
return Super::InputKey(EventArgs);
}
Common UI를 사용할 때에는 이것이 UCommonGameViewportClient::InputKey이다.
때문에 Common UI를 사용하려면 GameViewportClass가 CommonViewportClient로 설정되어야 한다.
Action Router Process
bool UCommonGameViewportClient::InputKey(const FInputKeyEventArgs& InEventArgs)
{
FInputKeyEventArgs EventArgs = InEventArgs;
if (IsKeyPriorityAboveUI(EventArgs))
{
return true;
}
// Check override before UI
if (OnOverrideInputKey().IsBound())
{
if (OnOverrideInputKey().Execute(EventArgs))
{
return true;
}
}
// The input is fair game for handling - the UI gets first dibs
#if !UE_BUILD_SHIPPING
if (ViewportConsole && !ViewportConsole->ConsoleState.IsEqual(NAME_Typing) && !ViewportConsole->ConsoleState.IsEqual(NAME_Open))
#endif
{
FReply Result = FReply::Unhandled();
if (!OnRerouteInput().ExecuteIfBound(EventArgs.InputDevice, EventArgs.Key, EventArgs.Event, Result))
{
HandleRerouteInput(EventArgs.InputDevice, EventArgs.Key, EventArgs.Event, Result);
}
if (Result.IsEventHandled())
{
return true;
}
}
return Super::InputKey(EventArgs);
}
Common UI의 Action Router Process는 Input이 CommonGameViewportClient에 전달되면서 시작합니다.
void UCommonGameViewportClient::HandleRerouteInput(FInputDeviceId DeviceId, FKey Key, EInputEvent EventType, FReply& Reply)
{
FPlatformUserId OwningPlatformUser = IPlatformInputDeviceMapper::Get().GetUserForInputDevice(DeviceId);
ULocalPlayer* LocalPlayer = GameInstance->FindLocalPlayerFromPlatformUserId(OwningPlatformUser);
Reply = FReply::Unhandled();
if (LocalPlayer)
{
UCommonUIActionRouterBase* ActionRouter = LocalPlayer->GetSubsystem<UCommonUIActionRouterBase>();
if (ensure(ActionRouter))
{
ERouteUIInputResult InputResult = ActionRouter->ProcessInput(Key, EventType);
if (InputResult == ERouteUIInputResult::BlockGameInput)
{
// We need to set the reply as handled otherwise the input won't actually be blocked from reaching the viewport.
Reply = FReply::Handled();
// Notify interested parties that we blocked the input.
OnRerouteBlockedInput().ExecuteIfBound(DeviceId, Key, EventType, Reply);
}
else if (InputResult == ERouteUIInputResult::Handled)
{
Reply = FReply::Handled();
}
}
}
}
UCommonGaameViewportClient::InputKey는 ActionLouter에 UCommonGameViewportClient::HandleRerouteInput의 입력을 처리할 기회를 제공한다.
ERouteUIInputResult UCommonUIActionRouterBase::ProcessInput(FKey Key, EInputEvent InputEvent) const
{
/*....*/
const auto ProcessNormalInputFunc = [Key, ActiveMode, this](EInputEvent Event)
{
bool bHandled = PersistentActions->ProcessNormalInput(ActiveMode, Key, Event);
if (!bHandled)
{
if (bIsActivatableTreeEnabled && ActiveRootNode)
{
bHandled = ActiveRootNode->ProcessNormalInput(ActiveMode, Key, Event);
}
if (!bHandled)
{
bHandled = ProcessInputOnActionDomains(ActiveMode, Key, Event);
}
}
return bHandled;
};
bool bHandledInput = ProcessHoldResult == EProcessHoldActionResult::Handled;
if (!bHandledInput)
{
if (ProcessHoldResult == EProcessHoldActionResult::GeneratePress)
{
// A hold action was in progress but quickly aborted, so we want to generate a press action now for any normal bindings that are interested
ProcessNormalInputFunc(IE_Pressed);
}
// Even if no widget cares about this input, we don't want to let anything through to the actual game while we're in menu mode
bHandledInput = ProcessNormalInputFunc(InputEvent);
}
if (bHandledInput)
{
return ERouteUIInputResult::Handled;
}
return CanProcessNormalGameInput() ? ERouteUIInputResult::Unhandled : ERouteUIInputResult::BlockGameInput;
}
ProcessInput은 Root Node에서 FActivatableTreeNode::ProcessNormalInput을 호출해 Input 처리 시도를 지시합니다.
이는 반복적으로 모든 Child Node에 ProcessNormalInput을 지시한다.
bool FActionRouterBindingCollection::ProcessNormalInput(ECommonInputMode ActiveInputMode, FKey Key, EInputEvent InputEvent) const
{
for (FUIActionBindingHandle BindingHandle : ActionBindings)
{
if (TSharedPtr<FUIActionBinding> Binding = FUIActionBinding::FindBinding(BindingHandle))
{
if (ActiveInputMode == ECommonInputMode::All || ActiveInputMode == Binding->InputMode)
{
auto TryConsumeInput = [&](const FKey& InKey, const UInputAction* InInputAction)
{
// A persistent displayed action skips the normal rules for reachability, since it'll always appear in a bound action bar
const bool bIsDisplayedPersistentAction = Binding->bIsPersistent && Binding->bDisplayInActionBar;
if (InKey == Key && Binding->InputEvent == InputEvent && (bIsDisplayedPersistentAction || IsWidgetReachableForInput(Binding->BoundWidget.Get())))
{
// Just in case this was in the middle of a hold process with a different key, reset now
Binding->CancelHold();
// If injecting enhanced input. don't fire 'OnExecuteAction' since that can be manually done if desired in BP
bool bEnhancedInputInjected = false;
if (InInputAction)
{
if (TObjectPtr<const UCommonInputMetadata> CommonInputMetadata = CommonUI::GetEnhancedInputActionMetadata(InInputAction))
{
// Non generic actions should inject enhanced input so users can bind to enhanced input events
if (!CommonInputMetadata->bIsGenericInputAction)
{
FInputActionValue RawValue = FInputActionValue(true);
CommonUI::InjectEnhancedInputForAction(GetActionRouter().GetLocalPlayerChecked(), InInputAction, RawValue);
bEnhancedInputInjected = true;
}
}
}
if (!bEnhancedInputInjected)
{
Binding->OnExecuteAction.ExecuteIfBound();
}
if (Binding->bConsumesInput)
{
return true;
}
}
return false;
};
if (CommonUI::IsEnhancedInputSupportEnabled() && Binding->InputAction.IsValid())
{
if (const UInputAction* InputAction = Binding->InputAction.Get())
{
TArray<FKey> InputActionKeys;
CommonUI::GetEnhancedInputActionKeys(GetActionRouter().GetLocalPlayerChecked(), InputAction, InputActionKeys);
for (const FKey& InputActionKey : InputActionKeys)
{
if (TryConsumeInput(InputActionKey, InputAction))
{
return true;
}
}
}
}
for (const FUIActionKeyMapping& KeyMapping : Binding->NormalMappings)
{
if (TryConsumeInput(KeyMapping.Key, nullptr))
{
return true;
}
}
}
}
}
return false;
}
FActivatableTreeNode는 FActionRouterBindingCollection으로, Node의 ActivatableWidget에 모든 ActionBinding List를 유지한다.
현재 Node 내의 모든 Child Node에서 Input을 처리하지 못하면, FActivatableTreeNode::ProcessNormalInput을 호출한다.
이 때 FActivatableTreeNode는 Binding Collection으로서 현재 Node는 Widget의 모든 Action Binding을 확인한다.
Action Binding이 해당 Key와 일치하는 경우, 관련 동작이 실행되고 Input이 처리된 것으로 간주된다.
Modify the Input Routing System
UCommonGameViewportClient를 상속받고 모든 Input Handling Method를 Override 하라.
이후 Project Setting에서 이 Class를 GameVieportClss로 설정한다.
UCommonUIActionRouterBase를 상속받고 모든 Virtual Function을 Override한다.
예를 들어, FUIInputconfig에서 ApplyUIInputConfig를 Override 할 수 있다.
Change UI Input Handling with Input config
현재 활성화 된 Widget을 기반으로 Application의 Input Handling이 변경되어야 하는 경우가 있다.
예를 들어 Social Sidebar 혹은 Pause Menu가 열려 있을 때 Player Input을 막는다던가.
이를 처리하기 위해 Common UI에서는 ActivatableWidget의 Inputconfig 옵션을 지원한다.
/**
* Gets the desired input configuration to establish when this widget activates and can receive input (i.e. all parents are also active).
* This configuration will override the existing one established by any previous activatable widget and restore it (if valid) upon deactivation.
*/
TOptional<FUIInputConfig> UCommonActivatableWidget::GetDesiredInputConfig() const
{
// Check if there is a BP implementation for input configs
if (GetClass()->IsFunctionImplementedInScript(GET_FUNCTION_NAME_CHECKED(UCommonActivatableWidget, BP_GetDesiredInputConfig)))
{
return BP_GetDesiredInputConfig();
}
// No particular config is desired by default
return TOptional<FUIInputConfig>();
}
ActivatableWidget을 활성화하면 GetDesiredInputConfig를 사용하여 InputConfig를 가져온다.
일반적으로는 null을 반환하지만 Override가 가능하다.
GetDesiredInputConfig가 Null을 반환할 때마다 Common UI는 마지막으로 유효했던 InputConfig로 되돌아간다.
/**
* Controls whether a default Input Config will be set when the active CommonActivatableWidgets do not specify a desired one.
* Disable this if you want to control the Input Mode via alternative means.
*/
UPROPERTY(config, EditAnywhere, Category = "Input")
bool bEnableDefaultInputConfig = true;
기본적으로 Common UI는 ActivatableWidget으로 지정되지 않는 경우 예비로 InputConfig를 적용한다.
하지만 위 변수를 사용해 이를 비활성화 할 수 있다.
void FActivatableTreeRoot::ApplyLeafmostNodeConfig()
{
#if WITH_EDITOR
if (!IsViewportWindowInFocusPath(GetActionRouter()))
{
return;
}
#endif
if (FActivatableTreeNodePtr PinnedLeafmostNode = LeafmostActiveNode.Pin())
{
GetActionRouter().SetActiveActivationMetadata(PinnedLeafmostNode->FindActivationMetadata());
if (ensure(PinnedLeafmostNode->IsReceivingInput()))
{
UE_LOG(LogUIActionRouter, Display, TEXT("Applying input config for leaf-most node [%s]"), *PinnedLeafmostNode->GetWidget()->GetName());
TOptional<FUIInputConfig> DesiredConfig = PinnedLeafmostNode->FindDesiredInputConfig();
if(DesiredConfig.IsSet())
{
GetActionRouter().SetActiveUIInputConfig(DesiredConfig.GetValue(), PinnedLeafmostNode->GetWidget());
}
else if(ICommonInputModule::GetSettings().GetEnableDefaultInputConfig())
{
// Nobody in the entire tree cares about the config and the default is enabled so fall back to the default
GetActionRouter().SetActiveUIInputConfig(FUIInputConfig());
}
FocusLeafmostNode();
}
else
{
UE_LOG(LogUIActionRouter, Log, TEXT("Didn't apply input config for leaf-most node [%s] because it's not receiving input right now"), *PinnedLeafmostNode->GetWidget()->GetName());
}
}
}
Widget이 비활성화 되면 Common UI는 이전 InputConfig를 복구한다.
이는 현재 Widget을 지원할 적절한 Inputconfig가 없이 중단되는 것을 방지하기 위함이다.
만약 UI의 모든 Widget이 비활성화 되면 CommonUI는 마지막으로 비활성화 된 Widget의 Default InputConfig를 사용한다.
이 경우 마지막으로 비활성화 된 Widget이 Soft Lock 방지를 위해 합리적인 InputConfig 상태를 다시 적용하도록 해야 한다.
Recommended Use
InputConfig를 사용하는 경우 UI에서 표준 InputConfig를 사용하지 않아야 한다.
void UCommonUIActionRouterBase::ApplyUIInputConfig(const FUIInputConfig& NewConfig, bool bForceRefresh)
{
if (bForceRefresh || NewConfig != ActiveInputConfig.GetValue())
{
UE_LOG(LogUIActionRouter, Display, TEXT("UIInputConfig being changed. bForceRefresh: %d"), bForceRefresh ? 1 : 0);
UE_LOG(LogUIActionRouter, Display, TEXT("\tInputMode: Previous (%s), New (%s)"),
ActiveInputConfig.IsSet() ? *StaticEnum<ECommonInputMode>()->GetValueAsString(ActiveInputConfig->GetInputMode()) : TEXT("None"), *StaticEnum<ECommonInputMode>()->GetValueAsString(NewConfig.GetInputMode()));
const ECommonInputMode PreviousInputMode = GetActiveInputMode();
TOptional<FUIInputConfig> OldConfig = ActiveInputConfig;
ActiveInputConfig = NewConfig;
ULocalPlayer& LocalPlayer = *GetLocalPlayerChecked();
// Note: may not work for splitscreen. We need per-player viewport client settings for mouse capture
if (UGameViewportClient* GameViewportClient = LocalPlayer.ViewportClient)
{
if (TSharedPtr<SViewport> ViewportWidget = GameViewportClient->GetGameViewportWidget())
{
if (APlayerController* PC = LocalPlayer.GetPlayerController(GetWorld()))
{
if (!OldConfig.IsSet() || OldConfig.GetValue().bIgnoreMoveInput != NewConfig.bIgnoreMoveInput)
{
PC->SetIgnoreMoveInput(NewConfig.bIgnoreMoveInput);
}
if (!OldConfig.IsSet() || OldConfig.GetValue().bIgnoreLookInput != NewConfig.bIgnoreLookInput)
{
PC->SetIgnoreLookInput(NewConfig.bIgnoreLookInput);
}
if (bAutoFlushPressedKeys && NewConfig.GetInputMode() == ECommonInputMode::Menu && PreviousInputMode != NewConfig.GetInputMode())
{
// Flushing pressed keys after switching to the Menu InputMode. This prevents the inputs from being artificially "held down".
// This needs to be delayed by one frame to successfully clear input captured at the end of this frame
GetWorld()->GetTimerManager().SetTimerForNextTick(this, &ThisClass::FlushPressedKeys);
}
const bool bWasCursorHidden = !PC->ShouldShowMouseCursor();
GameViewportClient->SetMouseCaptureMode(NewConfig.GetMouseCaptureMode());
GameViewportClient->SetHideCursorDuringCapture(NewConfig.HideCursorDuringViewportCapture() && !ShouldAlwaysShowCursor());
GameViewportClient->SetMouseLockMode(NewConfig.GetMouseLockMode());
FReply& SlateOperations = LocalPlayer.GetSlateOperations();
const EMouseCaptureMode CaptureMode = NewConfig.GetMouseCaptureMode();
switch (CaptureMode)
{
case EMouseCaptureMode::CapturePermanently:
case EMouseCaptureMode::CapturePermanently_IncludingInitialMouseDown:
{
PC->SetShowMouseCursor(ShouldAlwaysShowCursor() || !NewConfig.HideCursorDuringViewportCapture());
TSharedRef<SViewport> ViewportWidgetRef = ViewportWidget.ToSharedRef();
SlateOperations.UseHighPrecisionMouseMovement(ViewportWidgetRef);
SlateOperations.SetUserFocus(ViewportWidgetRef);
SlateOperations.CaptureMouse(ViewportWidgetRef);
if (GameViewportClient->ShouldAlwaysLockMouse() || GameViewportClient->LockDuringCapture() || !PC->ShouldShowMouseCursor())
{
SlateOperations.LockMouseToWidget(ViewportWidget.ToSharedRef());
}
else
{
SlateOperations.ReleaseMouseLock();
}
}
break;
case EMouseCaptureMode::NoCapture:
case EMouseCaptureMode::CaptureDuringMouseDown:
case EMouseCaptureMode::CaptureDuringRightMouseDown:
{
PC->SetShowMouseCursor(true);
SlateOperations.ReleaseMouseCapture();
if (GameViewportClient->ShouldAlwaysLockMouse())
{
SlateOperations.LockMouseToWidget(ViewportWidget.ToSharedRef());
}
else
{
SlateOperations.ReleaseMouseLock();
}
}
break;
}
// If the mouse was hidden previously, set it back to the center of the viewport now that we're showing it again
if (!bForceRefresh && bWasCursorHidden && PC->ShouldShowMouseCursor())
{
const ECommonInputType CurrentInputType = GetInputSubsystem().GetCurrentInputType();
bool bCenterCursor = true;
switch (CurrentInputType)
{
// Touch - Don't do it - the cursor isn't really relevant there.
case ECommonInputType::Touch:
bCenterCursor = false;
break;
// Gamepad - Let the settings tell us if we should center it.
case ECommonInputType::Gamepad:
break;
}
if (bCenterCursor)
{
TSharedPtr<FSlateUser> SlateUser = LocalPlayer.GetSlateUser();
TSharedPtr<IGameLayerManager> GameLayerManager = GameViewportClient->GetGameLayerManager();
if (ensure(SlateUser) && ensure(GameLayerManager))
{
FGeometry PlayerViewGeometry = GameLayerManager->GetPlayerWidgetHostGeometry(&LocalPlayer);
const FVector2D AbsoluteViewCenter = PlayerViewGeometry.GetAbsolutePositionAtCoordinates(FVector2D(0.5f, 0.5f));
SlateUser->SetCursorPosition(AbsoluteViewCenter);
UE_LOG(LogUIActionRouter, Verbose, TEXT("Moving the cursor to the viewport center."));
}
}
}
}
else
{
UE_LOG(LogUIActionRouter, Warning, TEXT("\tFailed to commit change! Local player controller was null."));
}
}
else
{
UE_LOG(LogUIActionRouter, Warning, TEXT("\tFailed to commit change! ViewportWidget was null."));
}
}
else
{
UE_LOG(LogUIActionRouter, Warning, TEXT("\tFailed to commit change! GameViewportClient was null."));
}
if (PreviousInputMode != NewConfig.GetInputMode())
{
OnActiveInputModeChanged().Broadcast(NewConfig.GetInputMode());
}
}
}
UCommonUIActionRouterBase::ApplyInputConfig에서는 Config Process 과정에서 다음 Unreal의 표준 InputConfig들을 기능의 일부로 호출한다.
UENUM(BlueprintType)
enum class ECommonInputMode : uint8
{
Menu UMETA(Tooltip = "Input is received by the UI only"),
Game UMETA(Tooltip = "Input is received by the Game only"),
All UMETA(Tooltip = "Input is received by UI and the Game"),
MAX UMETA(Hidden)
};
UENUM()
enum class EMouseCaptureMode : uint8
{
/** Do not capture the mouse at all */
NoCapture,
/** Capture the mouse permanently when the viewport is clicked, and consume the initial mouse down that caused the capture so it isn't processed by player input */
CapturePermanently,
/** Capture the mouse permanently when the viewport is clicked, and allow player input to process the mouse down that caused the capture */
CapturePermanently_IncludingInitialMouseDown,
/** Capture the mouse during a mouse down, releases on mouse up */
CaptureDuringMouseDown,
/** Capture only when the right mouse button is down, not any of the other mouse buttons */
CaptureDuringRightMouseDown,
};
UENUM()
enum class EMouseLockMode : uint8
{
/** Do not lock the mouse cursor to the viewport */
DoNotLock,
/** Only lock the mouse cursor to the viewport when the mouse is captured */
LockOnCapture,
/** Always lock the mouse cursor to the viewport */
LockAlways,
/** Lock the cursor if we're in fullscreen */
LockInFullscreen,
};
Change Widget Input Respons Using FReply
FReply는 InputEvent의 처리 상태를 추적한다.
/**
* An event should return a FReply::Handled() to let the system know that an event was handled.
*/
static FReply Handled( )
{
return FReply(true);
}
/**
* An event should return a FReply::Unhandled() to let the system know that an event was unhandled.
*/
static FReply Unhandled( )
{
return FReply(false);
}
Slate의 Input은 대부분 FReply::Handled나 FReply::Unhandled를 결과로 반환한다.
SWidget에서 사용되는 수 많은 Input Event들은 대부분 FReply를 반환한다.
이를 이용해 특정 FReply를 반환하여 원하는 결과를 얻게할 수 있다.
예를들어 특정 InputType의 처리를 허용 또는 중지한다던가.
하지만 대부분은 순정 FReply를 사용해도 크게 문제 없을 것이다.
FReply를 Customize 할 때에는 Slate Widget 작업에서 문제가 발생하는 경우가 많다.
FReply Settings
보통은 FReply의 Handled/UnHandled로만 상태를 추적하지만, 몇 가지 추가 데이터를 제공하기도 한다.
이러한 Method는 UMG나 Slate의 일부 Method와 비슷해 보일 수 있다.
하지만 기본적으로 FReply Namespace에 존재하며, Slate에서 FReply를 처리할 때 발생하는 동작을 수정한다.
FReply에서 이런 Method를 호출하면 FReply 외부의 동등한 Method를 호출한다.
이는 쉽게 복제할 수 없는 조금 다른 동작이 나올 수 있다.
CaptureMouse
/** An event should return a FReply::Handled().CaptureMouse( SomeWidget ) as a means of asking the system to forward all mouse events to SomeWidget */
FReply& CaptureMouse( TSharedRef<SWidget> InMouseCaptor )
{
this->MouseCaptor = InMouseCaptor;
return Me();
}
System에 특정 Widget으로 모든 MouseEvent를 전달하도록 요청
ClearUserFocus
/** An event should return a FReply::Handled().ClearUserFocus() to ask the system to clear user focus*/
FReply& ClearUserFocus(bool bInAllUsers = false)
{
return ClearUserFocus(EFocusCause::SetDirectly, bInAllUsers);
}
/** An event should return a FReply::Handled().ClearUserFocus() to ask the system to clear user focus*/
SLATECORE_API FReply& ClearUserFocus(EFocusCause ReasonFocusIsChanging, bool bInAllUsers = false)
{
this->FocusRecipient = nullptr;
this->FocusChangeReason = ReasonFocusIsChanging;
this->bReleaseUserFocus = true;
this->bSetUserFocus = false;
this->bAllUsers = bInAllUsers;
return Me();
}
System에 User Focus를 지우기를 요청
ReleaseMouseCapture
/**
* An event should return a FReply::Handled().ReleaseMouse() to ask the system to release mouse capture
* NOTE: Deactivates high precision mouse movement if activated.
*/
FReply& ReleaseMouseCapture()
{
this->MouseCaptor.Reset();
this->bReleaseMouseCapture = true;
this->bUseHighPrecisionMouse = false;
return Me();
}
System에 Mouse Capture 해제를 요청
SetUserFocus
/** An event should return FReply::Handled().SetUserFocus( SomeWidget ) as a means of asking the system to set users focus to the provided widget*/
SLATECORE_API FReply& SetUserFocus(TSharedRef<SWidget> GiveMeFocus, EFocusCause ReasonFocusIsChanging = EFocusCause::SetDirectly, bool bInAllUsers = false)
{
this->bSetUserFocus = true;
this->FocusRecipient = GiveMeFocus;
this->FocusChangeReason = ReasonFocusIsChanging;
this->bReleaseUserFocus = false;
this->bAllUsers = bInAllUsers;
return Me();
}
System에 User의 Focus를 제공된 Widget으로 설정하도록 요청
SetNavigation
/** An event should return FReply::Handled().SetNavigation( NavigationType ) as a means of asking the system to attempt a navigation*/
FReply& SetNavigation(EUINavigation InNavigationType, const ENavigationGenesis InNavigationGenesis, const ENavigationSource InNavigationSource = ENavigationSource::FocusedWidget)
{
this->NavigationType = InNavigationType;
this->NavigationGenesis = InNavigationGenesis;
this->NavigationSource = InNavigationSource;
this->NavigationDestination = nullptr;
return Me();
}
/** An event should return FReply::Handled().SetNavigation( NavigationDestination ) as a means of asking the system to attempt a navigation to the specified destination*/
FReply& SetNavigation(TSharedRef<SWidget> InNavigationDestination, const ENavigationGenesis InNavigationGenesis, const ENavigationSource InNavigationSource = ENavigationSource::FocusedWidget)
{
this->NavigationType = EUINavigation::Invalid;
this->NavigationGenesis = InNavigationGenesis;
this->NavigationSource = InNavigationSource;
this->NavigationDestination = InNavigationDestination;
return Me();
}
System에 지정된 대상으로의 Navigation을 시도하도록 요청
When would we set FReply?
예를 들어 특정 Key Input으로 Widget Focus를 설정하거나, 지워야 한다고 가정해보자.
보통은 FSlateInputApplication에서 Key Input Handler 관련 함수를 직접 호출할 것이다.
이 방식은 모든 경우에, 특히 Input Routing을 사용할 때 작동하지 않을 수 있다.
현재 Widget에서 Input이 처리되는 동안 Focus 변경/지우기가 발생할 수 있기 때문이다.
이를 대신하여 Input이 완전히 처리되고 나서 발생되는 FReply를 사용하는 것이 좋다.
과거에는 FReply API가 제어 또는 노출되는 상태일 때에는 FReply만 사용 하도록 하였다.
하지만 이 방식은 너무 제한적이라 위와 같이 언제든 사용 가능하도록 바뀌었다.
현재 버전에서 기존의 가이드 이상으로 강력히 권장되는 방식이다.
Customize Navigation
Navigation Config
/**
* Navigation context for event
*/
UENUM(BlueprintType)
enum class EUINavigation : uint8
{
/** Four cardinal directions*/
Left,
Right,
Up,
Down,
/** Conceptual next and previous*/
Next,
Previous,
/** Number of navigation types*/
Num UMETA(Hidden),
/** Denotes an invalid navigation, more important used to denote no specified navigation*/
Invalid,
};
Slate는 CommonUI 사용 여부와 무관하게 기본 Navigation을 지원한다.
/**
* Sets the navigation config. If you need to control navigation config dynamically, you
* should subclass FNavigationConfig to be dynamically adjustable to your needs.
*/
SLATE_API void SetNavigationConfig(TSharedRef<FNavigationConfig> InNavigationConfig)
{
NavigationConfig->OnUnregister();
NavigationConfig = InNavigationConfig;
NavigationConfig->OnRegister();
#if WITH_SLATE_DEBUGGING
TryDumpNavigationConfig(NavigationConfig);
#endif // WITH_SLATE_DEBUGGING
}
또한 FSlateUser::SetUserNavigationConfig를 호출해 User 기반으로 Navigation 구성을 설정할 수도 있다.
Manually Control Navigation
UMG에서 Widget을 선택하고 Details -> Navigation을 선택하면 Navigation Event 발생 시 어떻게 될지 수동으로 지정할 수 있다.
UENUM(BlueprintType)
enum class EUINavigationRule : uint8
{
/** Allow the movement to continue in that direction, seeking the next navigable widget automatically. */
Escape,
/** Move to a specific widget. */
Explicit,
/**
* Wrap movement inside this container, causing the movement to cycle around from the opposite side,
* if the navigation attempt would have escaped.
*/
Wrap,
/** Stops movement in this direction */
Stop,
/** Custom navigation handled by user code. */
Custom,
/** Custom navigation handled by user code if the boundary is hit. */
CustomBoundary,
/** Invalid Rule */
Invalid
};
Activatable Widgets and Action Bind
Set Focus for Activatable Widget on Activation
UWidget* UCommonActivatableWidget::GetDesiredFocusTarget() const
{
return NativeGetDesiredFocusTarget();
}
UWidget* UCommonActivatableWidget::NativeGetDesiredFocusTarget() const
{
// Prioritize BP implementation of this function.
UWidget* DesiredFocusTarget = BP_GetDesiredFocusTarget();
if (!DesiredFocusTarget)
{
// BP didn't specify focus target, fallback to DesiredFocusWidget property on UserWidget.
DesiredFocusTarget = GetDesiredFocusWidget();
}
return DesiredFocusTarget;
}
UWidget* UUserWidget::GetDesiredFocusWidget() const
{
return DesiredFocusWidget.GetWidget();
}
ActivatableWidget을 Activate할 때마다 GetDesiredFocusTarget이 호출된다.
이 함수는 User Input 발생 시 Common UI가 Focus해야 할 Widget을 선택한다.
해당 함수를 Customize 하지 않을 시 Common UI에서 Widget이 Active/Inactive 할 때마다 Focus 할 위치를 파악하는데 어려울 수 있다.
그러니 반드시 Customize를 하자.
Lyra에서는 ActivatableWidget에서 원하는 Focus Target을 가져오는데 사용할 Method를 Enum Type으로 결정한다.
이는 Focus 대상이 고정된 Async Method를 사용하는 경우에 권장된다.
대부분의 Menu에서라던가.
Change when Input Action Fire Triggered
Action Bind를 위해 FBindUIActionArgs를 생성할 때 KeyEvent를 InputAction Event를 Trigger해야 할 Type으로 설정한다.
Console Variable Reference
Use With Enhanced Input
Unreal Engine 5.2 이전 버전에서는 사용을 권장하지 않음.
Enhanced Input이 5.2버전 기준으로 테스트가 충분히 거치지 않음
주요 기능인 EnhancedInput의 PlayerMappableKeySettings가 아직 Experimental 단계임.
적어도 Beta가 되기 전까지는 시도를 안 하는 편이 좋음.
필수 사항
Common UI와 Enhanced Input을 모두 사용할 것
ViewportClass가 CommonGameViewportClient나 그 하위 Class일 것
CommonInputData에서 Accept/Back에 대한 Input Action이 등록되어 있을 것
설정법
Project Settings -> Game -> Common Input Settings에서 Enable Enhanced Input Support를 체크
Miscellaneous -> DataAsset을 선택해 CommonMappingContextMetadataInterface를 선택해 Asset 생성
해당 Asset은 Common UI Input Data를 설정할 수 있는 Metadata Object를 제공한다.
여기서 쓰이는 Nav Bar Priority는 Common UI와 동일한 개념이다.
만약 기능이 필요하다면 CommonInputMetaData를 상속받아 확장 가능하다.
위와 같이 설정
Per Action Enhanced Input Metadata
대응 될 InputAction BP
Action마다 Asset을 각자 만드는 대신, Per Action Enhanced Input Metadata를 사용해 한 Asset에서 여러 Action을 처리할 수 있다.
Is Generic Iput Action
true로 설정한 이유는 Common UI가 Input Action을 Broadcast하는 것을 방지하기 위함이다.
만약 Generic InputAction이 아닌 InputAction에서 사용하고 싶다면 이 값을 해제하면 된다.
이 경우, 해당 InputAction에는 Input을 Bind할 수 있다.
InputAction에서 Player Mappable Key Settings에서 Metadata 필드를 설정한다.
여기서 사용하는 Metadata는 위에서 만든 Asset이다.
이렇게 만들어진 InputAction은 Input을 받을 수 있는 Common UI에 연결해 사용할 수 있다.
CommonButtonBase
CommonActionWidget
UI가 아닌 InputAction의 Key를 표시할 수 있다.
CommonUIInputData
여기서 Default Navigation Action이 정의된다.
만약 이 옵션이 보이지 않는다면 Project Settings에서 Enable Enhanced Input Support 옵션을 확인해보자.
CommonActivatableWidget의 경우, Active 여부에 따라 적용/제거 될 IMC를 지정할 수 있다.
더 좋은 구조를 위해 게임 IMC를 적용 할 때 Common UI IMC를 같이 적용할 것을 권장한다.