[UE5] Slate를 이용해서 UI의 마우스 입력을 원하는대로 받기
사용자 위젯으로 UI를 만들때 마우스 이벤트를 내가 원하는 대로 받고싶을 때가 있다.
예를 들면 마우스 호버 이벤트를 버튼이 아닌 위젯에서도 받아오고 싶다거나, 클릭 이벤트를 마우스 오른쪽 버튼을 눌렀을 때와 왼쪽 버튼을 눌렀을 때에 서로 다르게 작동하게 하고싶은 경우가 있다.
이런 경우 기본적으로 제공하는 위젯을 사용하기보다 위젯을 새로 만들어 원하는대로 입력을 처리하는 것이 더 나을수도 있다.
여러 노노그램 게임에서 아래와 같이 칸을 클릭하고 드래그를 하면 여러 칸을 한번에 채울 수 있는 기능을 제공한다.
위 예시같은 경우 아래 웹사이트에서 플레이 가능하다.
Nonograms
onlinenonograms.com:443
현재 제작중인 노노그램 게임은 위젯을 이용해서 제작중이며, 각 칸을 버튼으로 구현해서 여러 칸을 채우기 위해선 한칸한칸씩 직접 클릭하는 방법밖에 없었다.
그러다보니 퍼즐의 크기가 커질수록 칸을 채우는것이 힘들어지고, 시간도 오래걸리게 된다.
그래서 위와같은 입력을 지원해서 여러 칸을 쉽게 채울 수 있게 구현하고자 했다.
그러기 위해선 마우스의 Press 이벤트, Release 이벤트, Hover 이벤트를 받아올 수 있어야한다.
언리얼 엔진에서 기본적으로 제공하는 위젯에서 이 3개의 이벤트를 모두 사용할 수 있는 것은 버튼뿐인듯 하다.
하지만 버튼을 이용해서 위와같은 기능을 구현하기에는 문제점이 있다.
버튼이 눌리면 버튼이 마우스 캡쳐를 하는데, 마우스가 캡쳐된 동안에는 마우스의 입력을 이 버튼이 모두 처리하게 된다.
그래서 버튼을 누른 상태에서 다른 버튼 위로 커서를 옮겨도 해당 버튼은 Hover 이벤트를 발생시키지 않고, 다른 버튼 위에서 마우스를 떼도 그 버튼에서 Release 이벤트가 발생하는것이 아니라 처음에 눌렀던 버튼에서 Release 이벤트가 발생한다.
이런 행동은 버튼의 디테일에서 클릭 매서드를 변경해도 바뀌지 않는다.
위 이미지처럼 MouseCapture로 인해서 버튼이 Press된 상태에서는 다른 버튼들의 Hover 이벤트가 발생하지 않고, Release 이벤트는 Press된 버튼에서만 발생한다.
이렇게 작동하면 입력을 원하는대로 받을 수 없게된다.
하지만 Hover 이벤트는 버튼을 통해 받아올 수 있기 때문에 Press와 Release 부분에서 조금 수정하면 되지 않을까 싶어서 소스코드를 분석해봤다.
UButton 분석
소스코드에서 블루프린트에 노출되는 델리게이트와 그 델리게이트가 호출되는 함수를 찾았다.
그 중에서 SlateHandleClicked()
를 더 찾아보니 다음과 같은곳에 바인딩 된 것을 알게됐다.
UWidget
에서 상속받은 인터페이스의 함수 중 RebuildWidget
을 구현한 코드다.
이 함수는 이 위젯이 가지고있는 SWidget
을 생성하는 역할을 한다.
UWidget
과 SWidget
이 있는데, UWidget
은 UObject
의 하위 클래스고, SWidget
은 슬레이트 위젯으로 서로 다른것이다.
언리얼 에디터에서 블루프린트를 통해 다룰 수 있는 위젯은 UWidget
이고, 실질적으로 화면에 나타나는 위젯은 SWidget
이다.
SWidget
에서 마우스 이벤트를 받아서 호출하면, 바인딩 된 UWidget
의 함수가 호출되는 방식이다.
UButton
에도 SButton
을 가지고있고, SButton
을 생성할 때 슬레이트의 기본값도 지정하고 이벤트도 바인딩한다.
그 다음 SButton
에서는 생성될 때 받은 Argument들을 멤버변수에 지정한다.
SWidget
에서 마우스 이벤트를 입력받으면 호출되는 함수들을 찾을 수 있다.
이 함수들에서 OnClicked
, OnPressed
등의 델리게이트가 호출된다.
virtual함수들이므로 SWidget
을 상속받은 슬레이트를 만들어서 적절하게 구현하면 된다.
준비
현재 노노그램에서는 퍼즐의 칸을 Cell이라고 지칭했으며, Cell을 구현한 위젯은 아래 이미지처럼 구성되어있다.
오버레이를 놓고 Cell을 나타낼 이미지 위에 버튼을 올려서 버튼의 마우스 입력을 받게 했다.
버튼의 색은 투명하게 해서 아래에 있는 이미지는 보이지만 마우스 입력은 버튼이 받도록 했다.
그러므로 새로 만들 위젯은 화면에 보여야할 필요가 없고, Cell 위에서 공간만 차지하면 된다.
이런 경우 슬레이트 위젯을 가지고있는 역할만 하는 UNativeWidgetHost
로 충분하다.
슬레이트 위젯은 SCompundWidget
을 상속받으면 된다.
각각 UNonogramCell
, SNonogramCell
이라고 지칭한다.
그 다음 프로젝트 파일의 Source 폴더 내부에 있는 *.Build.cs
파일에 모듈을 추가한다.
PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "UMG" });
PrivateDependencyModuleNames.AddRange(new string[] { });
// Uncomment if you are using Slate UI
PrivateDependencyModuleNames.AddRange(new string[] { "Slate", "SlateCore" });
첫번째 줄의 Public에 InputCore
와 UMG
를 추가하고, 아랫줄의 Private에 Slate
와 SlateCore
를 추가한다.
맨 마지막줄은 기본적으로 주석처리된 상태로 적혀있으니 주석만 해제하면 된다.
구현
UNonogramCell
에는 마우스 이벤트를 블루프린트에 노출할 델리게이트를 추가하고, 기본적으로 구현해야할 함수를 추가한다.
// NonogramCell.h
#pragma once
#include "CoreMinimal.h"
#include "Components/NativeWidgetHost.h"
#include "NonogramCell.generated.h"
class SNonogramCell;
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnClickedEvent);
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnPressedEvent);
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnReleasedEvent);
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnHoverEvent);
UCLASS()
class NONOGRAM_API UNonogramCell : public UNativeWidgetHost
{
GENERATED_BODY()
public:
virtual TSharedRef<SWidget> RebuildWidget() override;
void ReleaseSlateResources(bool bReleaseChildren);
UPROPERTY(BlueprintAssignable, Category = "Event")
FOnClickedEvent OnClicked;
UPROPERTY(BlueprintAssignable, Category = "Event")
FOnPressedEvent OnPressed;
UPROPERTY(BlueprintAssignable, Category = "Event")
FOnReleasedEvent OnReleased;
UPROPERTY(BlueprintAssignable, Category = "Event")
FOnHoverEvent OnHovered;
UPROPERTY(BlueprintAssignable, Category = "Event")
FOnHoverEvent OnUnhovered;
protected:
void SlateHandlePressed();
void SlateHandleReleased();
void SlateHandleHovered();
void SlateHandleUnhovered();
TSharedPtr<SNonogramCell> Cell;
};
멤버 변수로 슬레이트 위젯인 SNonogramCell
을 가리킬 포인터를 추가한다.
RebuildWidget
과 ReleaseSlateResource
함수는 필수로 구현해야한다.
ReleaseSlateResource
는 간단하게 다음과 같이 구현하면 된다.
void UNonogramCell::ReleaseSlateResources(bool bReleaseChildren)
{
Super::ReleaseSlateResources(bReleaseChildren);
Cell.Reset();
}
RebuildWidget
함수를 구현하기 전에 슬레이트 위젯을 먼저 세팅한다.
SCompoundWidget
을 상속받아서 C++파일을 추가하면 헤더파일에 아래와 같은 코드가 기본적으로 포함되어있다.
// SNonogramCell
#pragma once
#include "CoreMinimal.h"
#include "Widgets/SCompoundWidget.h"
/**
*
*/
class NONOGRAM_API SNonogramCell : public SCompoundWidget
{
public:
SLATE_BEGIN_ARGS(SNonogramCell)
{}
SLATE_END_ARGS()
/** Constructs this widget with InArgs */
void Construct(const FArguments& InArgs);
};
SLATE_BEGIN_ARGS(*)
와 SLATE_END_ARGS()
사이에 Argument로 받을 값들을 선언하면 된다.
이 부분은 SButton
을 참고해서 필요한 부분만 가져왔다.
SLATE_BEGIN_ARGS(SNonogramCell)
{
}
SLATE_EVENT(FSimpleDelegate, OnPressedArg)
SLATE_EVENT(FSimpleDelegate, OnReleasedArg)
SLATE_EVENT(FSimpleDelegate, OnHoveredArg)
SLATE_EVENT(FSimpleDelegate, OnUnhoveredArg)
SLATE_END_ARGS()
이곳에서 선언된 값들을 통해 UWidget
의 RebuildWidget
에서 슬레이트 위젯을 생성할 때 Argument를 받아올 수 있다.
그 다음 헤더파일은 다음과 같이 완성했다.
간단하게 Argument로 받은 델리게이트를 저장할 변수를 추가하고, SWidget
에서 필요한 함수를 override한다.
// SNonogram.h
#pragma once
#include "CoreMinimal.h"
#include "Widgets/SCompoundWidget.h"
class NONOGRAM_API SNonogramCell : public SCompoundWidget
{
public:
SLATE_BEGIN_ARGS(SNonogramCell)
{
}
SLATE_EVENT(FSimpleDelegate, OnPressedArg)
SLATE_EVENT(FSimpleDelegate, OnReleasedArg)
SLATE_EVENT(FSimpleDelegate, OnHoveredArg)
SLATE_EVENT(FSimpleDelegate, OnUnhoveredArg)
SLATE_END_ARGS()
/** Constructs this widget with InArgs */
void Construct(const FArguments& InArgs);
//~ SWidget overrides
virtual FReply OnMouseButtonDown(const FGeometry& MyGeometry, const FPointerEvent& MouseEvent) override;
virtual FReply OnMouseButtonUp(const FGeometry& MyGeometry, const FPointerEvent& MouseEvent) override;
virtual void OnMouseEnter(const FGeometry& MyGeometry, const FPointerEvent& MouseEvent) override;
virtual void OnMouseLeave(const FPointerEvent& MouseEvent) override;
private:
/** The delegate to execute when the button is pressed */
FSimpleDelegate OnPressed;
/** The delegate to execute when the button is released */
FSimpleDelegate OnReleased;
/** The delegate to execute when the button is hovered */
FSimpleDelegate OnHovered;
/** The delegate to execute when the button exit the hovered state */
FSimpleDelegate OnUnhovered;
};
그 다음 SNonogram
의 Construct
함수는 다음과 같이 구현한다.
BEGIN_SLATE_FUNCTION_BUILD_OPTIMIZATION
void SNonogramCell::Construct(const FArguments& InArgs)
{
OnPressed = InArgs._OnPressedArg;
OnReleased = InArgs._OnReleasedArg;
OnHovered = InArgs._OnHoveredArg;
OnUnhovered = InArgs._OnUnhoveredArg;
}
END_SLATE_FUNCTION_BUILD_OPTIMIZATION
Argument의 각 값들은 이름 앞에 _를 붙혀서 가져올 수 있다.
SWidget
에서 override한 함수들에선 간단하게 각 델리게이트들을 호출하면 된다.
FSimpleDelegate
는 ExecuteIfBound()
를 사용하면 된다.
예시는 다음과 같다.
FReply SNonogramCell::OnMouseButtonDown(const FGeometry& MyGeometry, const FPointerEvent& MouseEvent)
{
OnPressed.ExecuteIfBound();
return FReply::Handled();
}
FReply
는 입력이 처리되었으면 FReply::Handled()
, 그렇지 않으면 FReply::Unhandled()
를 리턴하면 된다.
SButton
의 경우 이 부분에서 FReply::Handled().CaptureMouse( AsShared() )
를 리턴해서 마우스가 이 SButton
을 캡쳐하도록 하기 때문에 글의 첫 부분에서 언급한 방식으로 작동한다.
이 슬레이트 위젯은 그런것은 원하지 않기 때문에 간단하게 FReply::Handled()
를 리턴한다.
다시 UNonogramCell
로 돌아가서 RebuildWidget
함수에서 슬레이트 위젯을 생성하는 코드를 추가한다.
TSharedRef<SWidget> UNonogramCell::RebuildWidget()
{
Cell = SNew(SNonogramCell)
.OnPressedArg(BIND_UOBJECT_DELEGATE(FSimpleDelegate, SlateHandlePressed))
.OnReleasedArg(BIND_UOBJECT_DELEGATE(FSimpleDelegate, SlateHandleReleased))
.OnHoveredArg_UObject(this, &ThisClass::SlateHandleHovered)
.OnUnhoveredArg_UObject(this, &ThisClass::SlateHandleUnhovered)
;
return Cell.ToSharedRef();
}
이때 OnHoveredArg
의 경우 뒤에 _UOjbect
가 붙어있는데 이런것은 경우에 따라서 선택하면 된다.
나는 SButton
을 참고해서 구현했기 때문에 SButton
에서 한 방식을 유지했다.
_UObject
말고 _Lambda
, _Raw
, _Static
이 있다.
그 다음 델리게이트에 바인딩 된 SlateHandlePressed
같은 UNonogramCell
의 함수들은 간단하게 블루프린트로 노출했던 델리게이트들을 Broadcast
하면 된다.
그 후에는 언리얼 엔진 에디터로 돌아가서 위젯을 추가하고 원하는 기능을 구현하면 된다.
슬레이트 위젯에 대해 더 알고싶으면 언리얼 엔진 문서를 보면 된다.
https://docs.unrealengine.com/5.2/ko/slate-user-interface-programming-framework-for-unreal-engine/
슬레이트 UI 프로그래밍
언리얼 엔진의 슬레이트 프레임워크로 유저 인터페이스를 프로그래밍합니다.
docs.unrealengine.com
결과
추가작업을 더 해서 목표로 정했던 기능을 구현했다.
간단하게 Press된 Cell과 Release된 Cell을 통해 어느 칸을 어떻게 채울지를 결정하면 된다.
드래그 중임을 시각적으로 알려주는 부분은 아직 구현이 안되어있어서 보는것만으로는 어떤 입력을 했는지 확실한 구분이 되지 않는 문제가 있다.
이 부분은 위젯의 Geometry에 대한 이해가 필요한듯한데 아직 모르는 부분이 많고, DPI 스케일도 영향을 끼치는 부분이 있는듯 해서 연구가 더 필요하다.
참고로 노노그램 게임을 위젯으로만 구현했기 때문에 첫번째 클릭시 입력이 꼬여서 Press 이벤트는 발생하지만 Release 이벤트는 발생하지 않고 계속 드래그한 상태처럼 유지되는 문제가 있었다.
해결 방법은 두가지가 있다.
1. 플레이어 컨트롤러의 InputMode
를 UI Only
로 변경.
플레이어 컨트롤러는 기본적으로 InputMode
가 Game And UI
로 되어있다.
그리고 퍼즐 화면의 배경은 UI가 아니라 월드 화면으로 구현되어있다.
그렇기 때문에 퍼즐 화면이 나오면 InputMode
가 Game
을 우선으로 처리하도록 하는듯 하다.
그 상황에서 UI를 클릭하게되니 입력이 꼬이는듯 하다.
그래서 플레이어 컨트롤러에서 InputMode
를 UI Only
로 지정해주니 해결되었다.
하지만 InputMode
를 UI Only
로 하면 입력 액션이 처리되지 않아 키보드같은 입력을 받지 못하는 문제가 있다.
입력 액션은 Game
으로 처리되기 때문이다.
2. 뷰포트의 MouseCaptureMode
를 NoCapture
로 설정
입력이 꼬이는 근본적인 문제가 뷰포트의 MouseCaptureMode
때문이었다.
버튼에서만 문제인줄 알았더니 뷰포트에서도 문제였다.
뷰포트 마우스 캡쳐 모드는 기본적으로 CapturePermanently_IncludingInitialMouseDown
으로 되어있다.
이 모드로 게임 실행시 마우스로 뷰포트를 클릭하면 마우스 커서가 사라지면서 게임에 마우스 입력이 들어가기 시작한다.
그때부터 마우스 캡쳐가 시작되는 것이다.
이런 경우 Shift + f1을 누르면 마우스 커서가 다시 등장하는데 이때 마우스 캡쳐가 풀린다.
현재 제작중인 노노그램 게임에서는 방해가 되는 요소이므로 간단하게 NoCapture
로 변경해서 해결했다.
UGameplayStatics
에 있는 함수이므로 C++에서도, 블루프린트에서도 설정 가능하고, 언리얼 에디터의 프로젝트 세팅 -> 엔진 -> 입력 -> Viewport Properties에서도 설정 가능하다.
- C++:
UGameplayStatics::SetViewportMouseCaptureMode
- 블루프린트:
Set Viewport Mouse Capture Mode
이 방법으로 입력 액션도 받아올 수 있게 유지하면서 첫 마우스 클릭시 입력이 꼬이는 문제도 해결했다.