特定の条件下において、ウィジェットのナビゲーション使用時にプレイヤー操作を行うとプレイヤー入力が残り続けることがある

お世話になっております。

表題の通り、プレイヤーの入力が残り続ける場合ありました。

条件としては、

  1. 長押し時にフォーカス対象の変更に待機時間を設ける(サンプルコード①を参照)
  2. 入力値をの更新をUEnhancedInputComponent::BindActionではなく、

UEnhancedInputComponent::GetBoundActionValueを使用する

以上を満たす場合に不具合が発生しておりました。

確認を行ったところ、​長押ししたキーを離したタイミングで UEnhancedPlayerInput::PrepareInputDelegatesForEvaluation 内の

KeyEvent が EKeyEvent::Noneになっているため、UEnhancedPlayerInput::ProcessActionMappingEvent 内の

パラメータ更新処理を通らず、キー入力が残ったままの状態でした(サンプルコード②を参照)。

以上を踏まえてご相談があります。​

1については​入力リピートの速度調整を行うため、2については入力内容を毎フレーム確認行うために実装しており、

いずれも現状の仕様上、変更は行わず対応したいと考えております。​

その前提において、本事象を改善する方法がございましたら、ご教授いただけますでしょうか。

サンプルコード①
​EUINavigation FCustomNavigationConfig::GetNavigationDirectionFromKey(const FKeyEvent& InKeyEvent) const
{
	if (const EUINavigation* Rule = KeyEventRules.Find(InKeyEvent.GetKey()))
	{
		if (bKeyNavigation)
		{
			if (*Rule != EUINavigation::Invalid)
			{
				FUserNavigationState& UserState = UserKeyNavigationState.FindOrAdd(InKeyEvent.GetUserIndex());
				FAnalogNavigationState& AnalogState = UserState.AnalogNavigationState.FindOrAdd(FAnalogNavigationKey(InKeyEvent.GetKey(), *Rule));

				const double CurrentTime = FApp::GetCurrentTime();
				if (!InKeyEvent.IsRepeat())
				{
					AnalogState.LastNavigationTime = 0.0;
					AnalogState.Repeats = 0;
				}

				constexpr float WaitTime = 1.0f;
				if (CurrentTime - AnalogState.LastNavigationTime > WaitTime)
				{
					AnalogState.LastNavigationTime = CurrentTime;
					AnalogState.Repeats++;
					return *Rule;
				}
			}

			return EUINavigation::Invalid;
		}
	}
	return EUINavigation::Invalid;
}


サンプルコード②
void UEnhancedPlayerInput::PrepareInputDelegatesForEvaluation(const TArray<UInputComponent*>& InputComponentStack, const float DeltaTime, const bool bGamePaused, const TArray<TPair<FKey, FKeyState*>>& KeysWithEvents)
{
    //省略
    EKeyEvent KeyEvent = bKeyIsHeld ? EKeyEvent::Held : ((bKeyIsDown || bKeyIsReleased) ? EKeyEvent::Actuated : EKeyEvent::None);
    //省略
}

void UEnhancedPlayerInput::ProcessActionMappingEvent(
    TObjectPtr<const UInputAction> Action,
    float DeltaTime,
    bool bGamePaused,
    FInputActionValue RawKeyValue,
    EKeyEvent KeyEvent,
    const TArray<UInputModifier*>& Modifiers,
    const TArray<UInputTrigger*>& Triggers,
    const bool bHasAlwaysTickTrigger /*= false*/)
{   
    //省略
    if (KeyEvent != EKeyEvent::None || bHasAlwaysTickTrigger)
    {
       //省略
       if(ModifiedValue.GetMagnitudeSq())
       {
          //省略
          //長押ししたキーを離したタイミングでこの処理が通らないため、パラメータが更新されない
          ActionData.Value = FInputActionValue(ValueType, Merged);
       }
    }
    //省略
}
  
  

[Attachment Removed]

再現手順
1.添付したSampleProjectを起動

2.Lvl_ThirdPersonを開く

3.PIEを実行

4. 1キーを押し、ウィジェットを表示

5.上キー or 下キーを長押し(プレイヤーが移動するまで)

6.長押ししているキーを離す

7.不具合が発生するまで5~6の手順を繰り返す

プレイヤーが移動したままになる

[Attachment Removed]

お世話になっております。

改善方法のご提案ありがとうございます。

ご提案いただいたコードにより、

・ウィジェットを開くまでは、方向キーを単押ししてキャラクターが動く

・ウィジェットを開いた後、方向キーを単押しするとキャラクターが動かない

・ウィジェットを開いた後、方向キーを長押しするとキャラクターが動く

・ウィジェットを開いた後、方向キーを長押しを中断すると、キャラクターが止まる

こちらの挙動が正しく行われていることを確認いたしました。

また、実装による影響についても、諸々承知いたしました。

InputPreProcessor についてですが、大変恐縮ながら、以前お送りいただいていたリンクの

内容を十分に把握できておりませんでした。確認が不足しており申し訳ございません。

理想の挙動としては InputPreProcessor を用いた方法なので、

こちらの方法についてもご相談させていただきたいです。

リンクを参考にして、HandleKeyDownEventとHandleKeyUpEvent内に

APlayerController::InputKeyを呼び出す処理を追加したところ、

・ウィジェットを開くまでは、方向キーを単押ししてキャラクターが動く

・ウィジェットを開いた後、方向キーを単押しするとキャラクターが動く

・ウィジェットを開いた後、方向キーを長押しするとキャラクターが動く

・ウィジェットを開いた後、方向キーを長押しを中断すると、キャラクターが止まる

以上の挙動となることが確認できました。

ですが、特定の機能で

・エディターのアウトライナーやコンテンツブラウザーの検索機能を使用中にも

プレイヤーの入力が効いてしまう

・UInputComponent::BindKey に割り当てたイベントがキー入力を

1回しか行っていないのに2回呼び出される

(ゲームウィンドウをクリックし、マウスカーソルがなくなっている状態で確認してください)

以上の不具合が発生するようになりました。

サンプルプロジェクトを再度添付いたしましたので、

こちらの原因及び改善方法について、ご教授いただけないでしょうか。

以上、よろしくお願いいたします。

[Attachment Removed]

お疲れ様です。

お忙しい中ご対応ありがとうございます。

確認させて頂きます。

[Attachment Removed]

お世話になっております。

非常に使いやすい再現プロジェクトをありがとうございます。

おかげさまで一発で再現を取ることができました。​

一点、仕様について確認させてください。ご報告いただいた内容と再現プロジェクトを確認し、当方では、

・ウィジェットを開くまでは、方向キーを単押ししてキャラクターが​動くのは正しい

・ウィジェットを開いた後、方向キーを単押しするとキャラクターが動かないのは正しい(ウィジェットのフォーカスが移動するのが正しい)​

・ウィジェットを開いた後、方向キーを長押しするとキャラクターが動き出すのは正しい(意図した挙動である)

・ウィジェットを開いた後、方向キーを長押しを中断すると、キャラクターが動き続けるのが正しくない​("入力が残り続ける"と報告された症状)

​という理解をしておりますが、この認識で間違いございませんでしょうか?

念のため確認させていただけますと幸いです。

以上、よろしくお願いいたします。​

[Attachment Removed]

お世話になっております。

ご確認いただきありがとうございます。

理想とする挙動は

・ウィジェットを開くまでは、方向キーを単押ししてキャラクターが動く

・ウィジェットを開いた後、方向キーを単押しするとキャラクターが動く

・ウィジェットを開いた後、方向キーを長押しするとキャラクターが動く

・ウィジェットを開いた後、方向キーを長押しを中断すると、キャラクターが止まる

上記の通りで、ウィジェットの表示・非表示にかかわらず、入力が効いてほしいです。

ただ、仕様上難しいところもあるかと思いますので、

・ウィジェットを開くまでは、方向キーを単押ししてキャラクターが動く

・ウィジェットを開いた後、方向キーを単押しするとキャラクターが動かない

・ウィジェットを開いた後、方向キーを長押しするとキャラクターが動く

・ウィジェットを開いた後、方向キーを長押しを中断すると、キャラクターが止まる

上記の通り、ご認識されている改善方法でも問題ありません。

以上、よろしくお願いいたします。

[Attachment Removed]

お世話になっております。

ご返信ありがとうございました。

理想の仕様のほう、把握いたしました。

ウィジェットの有無にかかわらず、PlayerInputは通常通り効いてほしいということであれば、すでにご検討いただいていると思いますが、InputPreProcessorを用いてSlateとPlayerInputの両方に入力を透過させるという方法があります。

[Content removed]

現在の実装ですと、ご認識いただいております通り、理想の仕様の実現は難しく、次善の仕様(当方認識の仕様)を目指す形にならざるを得ないと思われます。

そのため、

・単押しはNavigationが消化してしまうためキャラクターが動かず

・長押しを開始すると、長押しが効くまでのあいだSlateが入力を消費しないため、PlayerInputに入力が回り、キャラクターが動き、

・長押しがNavigation側で成立しはじめると、Slate側の入力消費が開始される(この間、ある意味で、キャラクターは「残った」入力で動いている)

という構造となってしまうことは避けられないと思います。

この入力消費の構造により、PlayerInput側の認識では一連の入力遷移(入力がない→押し始める→押し続ける→押すのをやめる→入力がない)が分断してしまい、キーを離した際に ActionData.Value をリセットできず、今回の症状に繋がっているようでした。

対応策として、EnhancedPlayerInput.cpp L:490 付近(UEnhancedPlayerInput::PrepareInputDelegatesForEvaluation()関数内)で以下のパッチをお試しください。

		bool bKeyIsReleased = !bKeyIsDown && bDownLastTick;
		bool bKeyIsHeld = bKeyIsDown && bDownLastTick;
 
+		const bool bHasUnmatchedRelease = !bKeyIsDown && !bDownLastTick &&
+			!bIsFlushingInputThisFrame &&
+			KeyState && KeyState->EventCounts[IE_Released].Num() > 0;
+		if (bHasUnmatchedRelease)
+		{
+			RawKeyValue = FVector::ZeroVector;
+		}
+		EKeyEvent KeyEvent = bKeyIsHeld ? EKeyEvent::Held :
+			((bKeyIsDown || bKeyIsReleased || bHasUnmatchedRelease) ? EKeyEvent::Actuated : EKeyEvent::None);
-		EKeyEvent KeyEvent = bKeyIsHeld ? EKeyEvent::Held : ((bKeyIsDown || bKeyIsReleased) ? EKeyEvent::Actuated : EKeyEvent::None);
 
		FVector* PressedThisTickValue = KeysPressedThisTick.Find(Mapping.Key);

処理の構造上、Slate/Navigation側で長押しが成立し入力消費が開始されると、PlayerInput側にその入力が流れていかないという限界がある点にはご注意ください。上述のパッチはあくまで入力の停止に対する判定を押し広げた形です。こうした実装上の限界により、再現プロジェクトにおいて、

・(例えば上キーを長押しして)長押しでナビゲーションとキャラクターの両方が動き始めたあと、「斜め押し」に移行してもキャラクターが反応しない

・アナログ入力の場合、ナビゲーションが反応を始めたあと、入力の強弱を変えてもキャラクターに反映されない

など、さまざまな症状が発生すると思われます。このような制限が御社のプロジェクトの仕様の範囲内であれば問題ないと思いますが、念のためご確認ください。

以上、よろしくお願いします。

[Attachment Removed]

お世話になっております。

InputPreprocessorを使用した再現プロジェクトのご提供をありがとうございます。

新たに①エディタUIへの入力混入と②BindKeyの二重発火という問題が出ましたが、それぞれ、

・①InputPreProcessorがFSlateApplication::ProcessKeyDownEventの冒頭(アプリ全体のキー処理をするタイミング)で呼び出され、PlayerController(以下PC)に入力を横流しするために発生

・②Slateに流した入力はUIで消費されなかった場合にViewport経由でPCに渡されるため、InputPreProcessorの入力横流しとあわせて2回発火

という原因があり、

・①については、エディタビルド時にPIEにフォーカスが当たっているかどうかをチェックし、PCへの入力横流しを実行する条件を絞ることで、

・②については、PC入力への横流しを行った際は、SlateからViewport経由でPCに入力を流す処理を止めることで、

解決が可能と考えております。

一部エンジン改造が必要ですが、以下の対策をお願いいたします。

■①

CustomInputPreprocessor.cpp

// 冒頭に以下のinclude追加
+ #include "Engine/GameViewportClient.h"
+ #include "Engine/LocalPlayer.h"
+ #include "Engine/World.h"
+ #include "Framework/Application/SlateApplication.h"
+ #include "GameFramework/PlayerController.h"
+ #include "Widgets/SViewport.h"
+ #include "Widgets/SWindow.h"
 
// ※.h にこの private 関数の宣言も追加すること
+ bool ICustomInputPreprocessor::ShouldRouteToPlayerInput(FSlateApplication& SlateApp) const
+ {
+     if (!World || !World->IsGameWorld())
+     {
+         return false;
+     }
+ 
+     UGameViewportClient* GameViewportClient = World->GetGameViewport();
+ 
+     if (!GameViewportClient)
+     {
+         return false;
+     }
+ 
+     TSharedPtr<SViewport> ViewportWidget = GameViewportClient->GetGameViewportWidget();
+     if (!ViewportWidget.IsValid())
+     {
+         return false;
+     }
+ 
+ #if WITH_EDITOR
+     if (World->IsPlayInEditor())
+     {
+         // Viewport が PIE 用 Viewport なのか確認
+         if (FViewport* GameViewport = GameViewportClient->Viewport)
+         {
+             if (!GameViewport->IsPlayInEditorViewport())
+             {
+                 return false;
+             }
+       }
+
+       // PIE Viewport へのフォーカス確認
+       if (!ViewportWidget->HasMouseCapture())
+       {
+           return false;
+       }
+    }
#endif
    return true;
}
 
bool ICustomInputPreprocessor::HandleKeyDownEvent(FSlateApplication& SlateApp, const FKeyEvent& InKeyEvent)
{
    IInputProcessor::HandleKeyDownEvent(SlateApp, InKeyEvent);
+   if (!ShouldRouteToPlayerInput(SlateApp))
+   {
+       return false;
+   }
 
// 略
 
bool ICustomInputPreprocessor::HandleKeyUpEvent(FSlateApplication& SlateApp, const FKeyEvent& InKeyEvent)
{
    IInputProcessor::HandleKeyUpEvent(SlateApp, InKeyEvent);
+   if (!ShouldRouteToPlayerInput(SlateApp))
+   {
+       return false;
+   }

■②

​SlateApplication.h

    /** Sets the handler for otherwise unhandled key down events. This is used by the editor to provide a global action list, if the key was not consumed by any widget. */
    SLATE_API void SetUnhandledKeyUpEventHandler(const FOnKeyEvent& NewHandler);
 
+   bool ShouldViewportSkipInputKeyForCurrentEvent() const { return bSuppressViewportInputKeyForCurrentEvent; }
+   void RequestSuppressViewportInputKeyForCurrentEvent() { bSuppressViewportInputKeyForCurrentEvent = true; }
 
+ private:
+   bool bSuppressViewportInputKeyForCurrentEvent = false;
 
+ public:
    /** [Content removed] or controller */
    double GetLastUserInteractionTime() const { return LastUserInteractionTime; }

SlateApplication.cpp

bool FSlateApplication::ProcessKeyDownEvent( const FKeyEvent& InKeyEvent )
{
    SCOPE_CYCLE_COUNTER(STAT_ProcessKeyDown);
​
#if WITH_SLATE_DEBUGGING
    FSlateDebugging::FScopeProcessInputEvent Scope(ESlateDebuggingInputEvent::KeyDown, InKeyEvent);
#endif
​
    TScopeCounter<int32> BeginInput(ProcessingInput);
​
    TSharedRef<FSlateUser> SlateUser = GetOrCreateUser(InKeyEvent);
​
#if WITH_EDITOR
    //Send the key input to all pre input key down listener function
    if (OnApplicationPreInputKeyDownListenerEvent.IsBound())
    {
        OnApplicationPreInputKeyDownListenerEvent.Broadcast(InKeyEvent);
    }
#endif //WITH_EDITOR
​
+   bSuppressViewportInputKeyForCurrentEvent = false;
​
// 略
 
bool FSlateApplication::ProcessKeyUpEvent( const FKeyEvent& InKeyEvent )
{
    SCOPE_CYCLE_COUNTER(STAT_ProcessKeyUp);
 
#if WITH_SLATE_DEBUGGING
    FSlateDebugging::FScopeProcessInputEvent Scope(ESlateDebuggingInputEvent::KeyUp, InKeyEvent);
#endif
 
    TScopeCounter<int32> BeginInput(ProcessingInput);
+   bSuppressViewportInputKeyForCurrentEvent = false;

SceneViewport.cpp

// ほぼ同じ構造のFReply FSceneViewport::OnKeyDownも改造すること
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);
 
+			const bool bSkipViewportInputKey = FSlateApplication::IsInitialized()
+				&& FSlateApplication::Get().ShouldViewportSkipInputKeyForCurrentEvent();
+			if (bSkipViewportInputKey)
+			{
+				CurrentReplyState = FReply::Unhandled();
+			}
+			else
			{
				if (!ViewportClient->InputKey(FInputKeyEventArgs(this, InKeyEvent.GetInputDeviceId(), Key, InKeyEvent.IsRepeat() ? IE_Repeat : IE_Pressed, 1.0f, false, InKeyEvent.GetEventTimestamp())))
				{
					CurrentReplyState = FReply::Unhandled();
				}
			}

あとは、ICustomInputPreprocessor::HandleKeyDownEvent()、HandleKeyUpEvent() それぞれの関数内で、PC->InputKey(Args); の直後にこの新しい Slate 関数の呼び出しを仕込みます。

// ICustomInputPreprocessor::HandleKeyDownEvent() 内
    if (Viewport)
    {
        FInputKeyEventArgs Args(
            Viewport,
            InKeyEvent.GetInputDeviceId(),
            InKeyEvent.GetKey(),
             InKeyEvent.IsRepeat() ? IE_Repeat : IE_Pressed
        );
        PC->InputKey(Args);
+     SlateApp.RequestSuppressViewportInputKeyForCurrentEvent();  
 
// ICustomInputPreprocessor::HandleKeyUpEvent() でも同様

一度お試しください。

以上、よろしくお願いします。

[Attachment Removed]

お世話になっております。

参考までに、patch形式のものも用意しましたので、本返信に添付いたします。

よろしくお願いいたします。

[Attachment Removed]

ご返信ありがとうございます。

それでは、お手すきの際にでもご確認いただけますと幸いです。

よろしくお願いいたします。​

[Attachment Removed]