언리얼엔진/노노그램

[UE5] Slate를 이용해서 UI의 마우스 입력을 원하는대로 받기

mstone8370 2023. 8. 27. 23:08

 

 

사용자 위젯으로 UI를 만들때 마우스 이벤트를 내가 원하는 대로 받고싶을 때가 있다.

예를 들면 마우스 호버 이벤트를 버튼이 아닌 위젯에서도 받아오고 싶다거나, 클릭 이벤트를 마우스 오른쪽 버튼을 눌렀을 때와 왼쪽 버튼을 눌렀을 때에 서로 다르게 작동하게 하고싶은 경우가 있다.

이런 경우 기본적으로 제공하는 위젯을 사용하기보다 위젯을 새로 만들어 원하는대로 입력을 처리하는 것이 더 나을수도 있다.

 

 


 

 

여러 노노그램 게임에서 아래와 같이 칸을 클릭하고 드래그를 하면 여러 칸을 한번에 채울 수 있는 기능을 제공한다.

위 예시같은 경우 아래 웹사이트에서 플레이 가능하다.

http://onlinenonograms.com

 

Nonograms

 

onlinenonograms.com:443

 

현재 제작중인 노노그램 게임은 위젯을 이용해서 제작중이며, 각 칸을 버튼으로 구현해서 여러 칸을 채우기 위해선 한칸한칸씩 직접 클릭하는 방법밖에 없었다.

그러다보니 퍼즐의 크기가 커질수록 칸을 채우는것이 힘들어지고, 시간도 오래걸리게 된다.

그래서 위와같은 입력을 지원해서 여러 칸을 쉽게 채울 수 있게 구현하고자 했다.

 

그러기 위해선 마우스의 Press 이벤트, Release 이벤트, Hover 이벤트를 받아올 수 있어야한다.

언리얼 엔진에서 기본적으로 제공하는 위젯에서 이 3개의 이벤트를 모두 사용할 수 있는 것은 버튼뿐인듯 하다.

하지만 버튼을 이용해서 위와같은 기능을 구현하기에는 문제점이 있다.

버튼이 눌리면 버튼이 마우스 캡쳐를 하는데, 마우스가 캡쳐된 동안에는 마우스의 입력을 이 버튼이 모두 처리하게 된다.

그래서 버튼을 누른 상태에서 다른 버튼 위로 커서를 옮겨도 해당 버튼은 Hover 이벤트를 발생시키지 않고, 다른 버튼 위에서 마우스를 떼도 그 버튼에서 Release 이벤트가 발생하는것이 아니라 처음에 눌렀던 버튼에서 Release 이벤트가 발생한다.

이런 행동은 버튼의 디테일에서 클릭 매서드를 변경해도 바뀌지 않는다.

 

버튼의 MouseCapture가 작동하는 예시

위 이미지처럼 MouseCapture로 인해서 버튼이 Press된 상태에서는 다른 버튼들의 Hover 이벤트가 발생하지 않고, Release 이벤트는 Press된 버튼에서만 발생한다.

이렇게 작동하면 입력을 원하는대로 받을 수 없게된다.

 

하지만 Hover 이벤트는 버튼을 통해 받아올 수 있기 때문에 Press와 Release 부분에서 조금 수정하면 되지 않을까 싶어서 소스코드를 분석해봤다.

 

 

UButton 분석

UButton 의 Delegate

소스코드에서 블루프린트에 노출되는 델리게이트와 그 델리게이트가 호출되는 함수를 찾았다.

그 중에서 SlateHandleClicked()를 더 찾아보니 다음과 같은곳에 바인딩 된 것을 알게됐다.

 

UButton::RebildWidget
UButton의 멤버변수

UWidget에서 상속받은 인터페이스의 함수 중 RebuildWidget을 구현한 코드다.

이 함수는 이 위젯이 가지고있는 SWidget을 생성하는 역할을 한다.

UWidgetSWidget이 있는데, UWidgetUObject의 하위 클래스고, SWidget은 슬레이트 위젯으로 서로 다른것이다.

언리얼 에디터에서 블루프린트를 통해 다룰 수 있는 위젯은 UWidget이고, 실질적으로 화면에 나타나는 위젯은 SWidget이다.

SWidget에서 마우스 이벤트를 받아서 호출하면, 바인딩 된 UWidget의 함수가 호출되는 방식이다.

UButton에도 SButton을 가지고있고, SButton을 생성할 때 슬레이트의 기본값도 지정하고 이벤트도 바인딩한다.

그 다음 SButton에서는 생성될 때 받은 Argument들을 멤버변수에 지정한다.

 

SButton::Construct
SButton이 override한 SWidget의 함수들

SWidget에서 마우스 이벤트를 입력받으면 호출되는 함수들을 찾을 수 있다.

이 함수들에서 OnClicked, OnPressed 등의 델리게이트가 호출된다.

virtual함수들이므로 SWidget을 상속받은 슬레이트를 만들어서 적절하게 구현하면 된다.

 

 


 

 

준비

현재 노노그램에서는 퍼즐의 칸을 Cell이라고 지칭했으며, Cell을 구현한 위젯은 아래 이미지처럼 구성되어있다.

 

WBP_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에 InputCoreUMG를 추가하고, 아랫줄의 Private에 SlateSlateCore를 추가한다.

맨 마지막줄은 기본적으로 주석처리된 상태로 적혀있으니 주석만 해제하면 된다.

 

 

 

구현

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을 가리킬 포인터를 추가한다.

RebuildWidgetReleaseSlateResource 함수는 필수로 구현해야한다.

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()

이곳에서 선언된 값들을 통해 UWidgetRebuildWidget에서 슬레이트 위젯을 생성할 때 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;
};

그 다음 SNonogramConstruct 함수는 다음과 같이 구현한다.

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한 함수들에선 간단하게 각 델리게이트들을 호출하면 된다.

FSimpleDelegateExecuteIfBound()를 사용하면 된다.

예시는 다음과 같다.

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. 플레이어 컨트롤러의 InputModeUI Only로 변경.

플레이어 컨트롤러는 기본적으로 InputModeGame And UI로 되어있다.

그리고 퍼즐 화면의 배경은 UI가 아니라 월드 화면으로 구현되어있다.

그렇기 때문에 퍼즐 화면이 나오면 InputModeGame을 우선으로 처리하도록 하는듯 하다.

그 상황에서 UI를 클릭하게되니 입력이 꼬이는듯 하다.

그래서 플레이어 컨트롤러에서 InputModeUI Only로 지정해주니 해결되었다.

 

하지만 InputModeUI Only로 하면 입력 액션이 처리되지 않아 키보드같은 입력을 받지 못하는 문제가 있다.

입력 액션은 Game으로 처리되기 때문이다.

 

 

2. 뷰포트의 MouseCaptureModeNoCapture로 설정

입력이 꼬이는 근본적인 문제가 뷰포트의 MouseCaptureMode 때문이었다.

버튼에서만 문제인줄 알았더니 뷰포트에서도 문제였다.

뷰포트 마우스 캡쳐 모드는 기본적으로 CapturePermanently_IncludingInitialMouseDown으로 되어있다.

이 모드로 게임 실행시 마우스로 뷰포트를 클릭하면 마우스 커서가 사라지면서 게임에 마우스 입력이 들어가기 시작한다.

그때부터 마우스 캡쳐가 시작되는 것이다.

이런 경우 Shift + f1을 누르면 마우스 커서가 다시 등장하는데 이때 마우스 캡쳐가 풀린다.

현재 제작중인 노노그램 게임에서는 방해가 되는 요소이므로 간단하게 NoCapture로 변경해서 해결했다.

UGameplayStatics에 있는 함수이므로 C++에서도, 블루프린트에서도 설정 가능하고, 언리얼 에디터의 프로젝트 세팅 -> 엔진 -> 입력 -> Viewport Properties에서도 설정 가능하다.

  • C++: UGameplayStatics::SetViewportMouseCaptureMode
  • 블루프린트: Set Viewport Mouse Capture Mode

 

이 방법으로 입력 액션도 받아올 수 있게 유지하면서 첫 마우스 클릭시 입력이 꼬이는 문제도 해결했다.