Common UI Radial menu?

How can I create a radial menu with common UI. I have searched everywhere and found nothing. I need it to work with mouse and gamepad like fortnite’s emote wheel and I want it to use enhanced input so that I can have deadzone on the gamepad joystick. I have created the actual widget but need to make the selecting segment work with gamepad and mouse. Does anyone have any ideas please?

Thank you!

Hi There,

Honestly I never do this before in Unreal atleast so it was a nice problem for me to solve also exercise, so thanks for it. I will try to make this with excuse like a tutorial simple but still a prototype here so it becomes something valuable for more devs I hope.

I made a UI Material custom node, I typed some constraints to AI and what it should use how it should appear and generated a HLSL graph. Suprisingly it worked :slight_smile:

It is code is here

const float DEG2RAD = 0.017453292519943295;

// Fixed apex (left middle) and facing (right)
float2 apex = float2(0.0, 0.5);
float2 d    = float2(1.0, 0.0);

// UV vector from apex
float2 p = UV - apex;
float  r = length(p);

// Avoid NaNs
float2 v = (r > 1e-6) ? (p / r) : d;

// ----- Angular mask (hard) -----
float halfA = (AngleDeg * 0.5) * DEG2RAD;
float aDot  = dot(v, d);
float angMask = step(cos(halfA), aDot);   // inside wedge

// ----- Radial mask (hard circle, radius = 1 UV unit) -----
float radMask = 1.0 - step(1.0, r);       // r < 1

// ----- Combine -----
return angMask * radMask;

After this created one widget to act like a button. I won’t go into details of its usability for rotations hower with same approaches it can be extended.

Inside there is nothing for now, this is a custom button, thing you can manipulate common ui button or take a different approach as well, it has some events like OnStateChanged OnInteracted like every button should have. Important part at preconstruct I created a dynamic material of the slice material and assigned to necessary parts. Also kept reference to the material since we would like to have it dynamic so our system becomes somewhat extendable. Also can be a function where calculates whatever element shoulld orient relative to angle, like icons, texts, etc. It’s design dependent think its not a big deal.

Added some of my buttons to a parent Radial_WGT put a canvas and overlay for positioning and scaling etc

/

Get the children, cast them to its class and save them as array, then for each button i set its material angle, render transform angle, pivot and increment. These things can be more manual or you can automise the angle by dividing 360/button count ofcourse. Additionally binded into the clicked function onContstruct

Test and looks like this now we have a working radial menu, atleast with hower function. Not going deep into hitbox zones, mouse behaviours in center and general further usability aspects of interactions. However hover/hitbox zones can be changed and manually controlled with the next subject will go into these since cardinal calculations are more accurate then hower in radial.
1

After the logic parent widget looks like ok

In my pawn/character I set input mode UI+Game, If we set to UI only then we have to get inputs with native OnKeydowns or we can tunnel position of cardinals into widget however I took this approach for the sake of it so that we can access EnhancedInput inside the widget directly, additionally printed my left stick moves, its axis/cardinals.

Extended logic under widget with direct input, get axis, converted to radians to degrees and percent modulate to 360, with a select function from Left to Righ, Up to Down I have a 360 degree (counterclockwise) rotation. Then I get this degree and divided into my current angle delta which is 60 (we can automate this too) and this basically gives me index for 60 degrees 6 buttons 0-5.

I have a Deselect all which tell all buttons to go unselected state and a SetHover() which compares current index to incoming and if different sets button to hover state for incoming state + updates index.

I encountered a bug which when i go gamepad input mode in editor in windows makes my cursor go to a place near the middle of screen, which makes radial 1st button to hover. So I added a middle circle which is functionally prevents that also looks more nice as a radial menu.

Results.
2

Let me know if this is what you are looking for and it helps. Improvements and polish can be made easy depending on the design and aspects and interface behaviour desired.

Improvement points:
-CustomRadialButton with input angle calculating all it’s child orientations.
-CustomRadialButton, draw detection. This is more complex can be done so even in high density radials, edges are accurate. This can be done with widget geometry bp I assume but can be easier from C++

  • Various cosmetics, sounds and confirmations
  • OnGamepad Input keeping hover.
  • Call to action functions, confirmations, closing and escape functions.
  • For high paced games auto confirmation on drag or drag threshold to confirms.
3 Likes

More Advanced Version of Radial Menu
(With Integrated GamePad / KBM Interactions and Common Interactions Scenarios Integrated)

1 - Created RadialMenu IA and Integrated them in mapping context. Triggers as Pressed and Released.

2- A - Created a UI material that acts like a slice from left to right with angles as custom node. It has some color variables and opacity but important part is to have a correct angle on it. Additionally same script is used to calculate a ProgressBar at the right edge of the material which can be controlled by parameters.

2- B - Custom HLSL node code is like below

// Constants
const float DEG2RAD = 0.017453292519943295;

// Fixed apex (left middle) and facing (right)
float2 apex = float2(0.0, 0.5);
float2 d    = float2(1.0, 0.0);

// UV vector from apex
float2 p = UV - apex;
float  r = length(p);

// Avoid NaNs
float2 v = (r > 1e-6) ? (p / r) : d;

// ----- Angular mask (hard) -----
float halfA = (AngleDeg * 0.5) * DEG2RAD;
float aDot  = dot(v, d);
float angMask = step(cos(halfA), aDot);   // inside wedge

// ----- Radial mask (hard circle, radius = 1 UV unit) -----
float radMask = 1.0 - step(1.0, r);       // r < 1

// ----- Combine -----
return angMask * radMask;

3- A : Creating Custom Content Widget with Custom Hitbox : This stage is C++ however can be neglected in general radial menu interaction methods it’s recommended for flexibility and accuracy of the system for an device agnostic interaction. If a hitbox method should be used it can be done like below by adding a custom widget with custom hitbox.

MyQuadWidget.h

Summary
// MyQuadWidget.h
#pragma once
#include "CoreMinimal.h"
#include "Components/ContentWidget.h"
#include "MyQuadWidget.generated.h"

DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnQuadSimple);

UCLASS(meta=(DisplayName="My Quad Content Widget"))
class UMyQuadWidget : public UContentWidget
{
	GENERATED_BODY()

public:
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Shape", meta=(ExposeOnSpawn=true, BlueprintSetter=SetCornerA))
	FVector2D CornerA = FVector2D(0.0f, 0.5f);
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Shape", meta=(ExposeOnSpawn=true, BlueprintSetter=SetCornerB))
	FVector2D CornerB = FVector2D(1.0f, 0.0f);
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Shape", meta=(ExposeOnSpawn=true, BlueprintSetter=SetCornerC))
	FVector2D CornerC = FVector2D(1.0f, 1.0f);
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Shape", meta=(ExposeOnSpawn=true, BlueprintSetter=SetCornerD))
	FVector2D CornerD = FVector2D(0.0f, 0.5f);

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Appearance", meta=(ClampMin="0.0"))
	float EditorOutlineThickness = 2.0f;
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Appearance")
	FLinearColor EditorOutlineColor = FLinearColor(0.15f, 0.85f, 0.25f, 1.0f);

	UPROPERTY(BlueprintAssignable, Category="Quad|Events") FOnQuadSimple OnPressed;
	UPROPERTY(BlueprintAssignable, Category="Quad|Events") FOnQuadSimple OnReleased;
	UPROPERTY(BlueprintAssignable, Category="Quad|Events") FOnQuadSimple OnClicked;
	UPROPERTY(BlueprintAssignable, Category="Quad|Events") FOnQuadSimple OnEntered;
	UPROPERTY(BlueprintAssignable, Category="Quad|Events") FOnQuadSimple OnLeft;

	UFUNCTION(BlueprintCallable, Category="Quad") void SetCorners(FVector2D A, FVector2D B, FVector2D C, FVector2D D);
	UFUNCTION(BlueprintSetter) void SetCornerA(FVector2D InA);
	UFUNCTION(BlueprintSetter) void SetCornerB(FVector2D InB);
	UFUNCTION(BlueprintSetter) void SetCornerC(FVector2D InC);
	UFUNCTION(BlueprintSetter) void SetCornerD(FVector2D InD);

	virtual void SynchronizeProperties() override;
	virtual void ReleaseSlateResources(bool bReleaseChildren) override;

protected:
	virtual UClass* GetSlotClass() const override { return UPanelSlot::StaticClass(); }
	virtual void OnSlotAdded(UPanelSlot*) override {}
	virtual void OnSlotRemoved(UPanelSlot*) override {}
	virtual TSharedRef<SWidget> RebuildWidget() override;

#if WITH_EDITOR
	virtual void PostEditChangeProperty(FPropertyChangedEvent& E) override
	{
		Super::PostEditChangeProperty(E);
		SynchronizeProperties();
		InvalidateLayoutAndVolatility();
	}
#endif

private:
	void PushToSlate();
	TArray<FVector2D> BuildNormQuad() const;

private:
	TSharedPtr<class SMyQuadPanel> SlatePanel;

	void SlatePressed()  { OnPressed.Broadcast(); }
	void SlateReleased() { OnReleased.Broadcast(); }
	void SlateClicked()  { OnClicked.Broadcast(); }
	void SlateEntered()  { OnEntered.Broadcast(); }
	void SlateLeft()     { OnLeft.Broadcast(); }
};


// ----- Slate -----
class SMyQuadPanel : public SCompoundWidget
{
public:
	SLATE_BEGIN_ARGS(SMyQuadPanel) {}
		SLATE_ARGUMENT(TArray<FVector2D>, NormQuad)
		SLATE_ARGUMENT(float,         EditorOutlineThickness)
		SLATE_ARGUMENT(FLinearColor,  EditorOutlineColor)
		SLATE_EVENT(FSimpleDelegate,  OnPressed)
		SLATE_EVENT(FSimpleDelegate,  OnReleased)
		SLATE_EVENT(FSimpleDelegate,  OnClicked)
		SLATE_EVENT(FSimpleDelegate,  OnEntered)
		SLATE_EVENT(FSimpleDelegate,  OnLeft)
		SLATE_DEFAULT_SLOT(FArguments, Content)
	SLATE_END_ARGS()

	void Construct(const FArguments& InArgs);
	void SetNormQuad(const TArray<FVector2D>& In);
	void SetEditorOutline(float Thickness, FLinearColor Color);
	void SetChildRef(const TSharedRef<SWidget>& InChild);

	virtual bool SupportsCustomHitTesting() const { return true; }
	virtual TSharedPtr<FVirtualPointerPosition> TranslateMouseCoordinateForCustomHitTesting(
		const FGeometry& Geo, const FVector2D& AbsolutePos) const;

	virtual void OnMouseEnter(const FGeometry& Geo, const FPointerEvent& Ev) override;
	virtual void OnMouseLeave(const FPointerEvent& Ev) override;
	virtual FReply OnMouseMove(const FGeometry& Geo, const FPointerEvent& Ev) override;
	virtual FReply OnMouseButtonDown(const FGeometry& Geo, const FPointerEvent& Ev) override;
	virtual FReply OnMouseButtonUp(const FGeometry& Geo, const FPointerEvent& Ev) override;

	virtual int32 OnPaint(const FPaintArgs& Args, const FGeometry& Geo, const FSlateRect& Clip,
	                      FSlateWindowElementList& Out, int32 Layer, const FWidgetStyle& Style,
	                      bool bParentEnabled) const override;

private:
	TArray<FVector2D> ToLocal(const FGeometry& Geo) const;
	static FVector2D Clamp01(const FVector2D& V)
	{
		return FVector2D(FMath::Clamp(V.X, 0.f, 1.f), FMath::Clamp(V.Y, 0.f, 1.f));
	}
	static bool PointInQuad(const FVector2D& P, const TArray<FVector2D>& Q);
	static bool SameSide2D(const FVector2D& P, const FVector2D& A, const FVector2D& B, const FVector2D& Ref);

private:
	TArray<FVector2D> Norm;
	float       Thick = 0.0f;
	FLinearColor Col  = FLinearColor::White;

	FSimpleDelegate OnPressedDelegate;
	FSimpleDelegate OnReleasedDelegate;
	FSimpleDelegate OnClickedDelegate;
	FSimpleDelegate OnEnteredDelegate;
	FSimpleDelegate OnLeftDelegate;

	bool bPressedInside = false;
	bool bHoverInside = false;
};

MyQuadWidget.cpp

Summary
// MyQuadWidget.cpp
#include "MyQuadWidget.h"
#include "Widgets/SCompoundWidget.h"
#include "Rendering/DrawElements.h"

TSharedRef<SWidget> UMyQuadWidget::RebuildWidget()
{
	SlatePanel = SNew(SMyQuadPanel)
		.NormQuad(BuildNormQuad())
		.EditorOutlineThickness(EditorOutlineThickness)
		.EditorOutlineColor(EditorOutlineColor)
		.OnPressed(FSimpleDelegate::CreateUObject(this, &UMyQuadWidget::SlatePressed))
		.OnReleased(FSimpleDelegate::CreateUObject(this, &UMyQuadWidget::SlateReleased))
		.OnClicked(FSimpleDelegate::CreateUObject(this, &UMyQuadWidget::SlateClicked))
		.OnEntered(FSimpleDelegate::CreateUObject(this, &UMyQuadWidget::SlateEntered))
		.OnLeft(FSimpleDelegate::CreateUObject(this, &UMyQuadWidget::SlateLeft))
		[
			GetContent() ? GetContent()->TakeWidget() : SNullWidget::NullWidget
		];
	return SlatePanel.ToSharedRef();
}

void UMyQuadWidget::SynchronizeProperties()
{
	Super::SynchronizeProperties();
	PushToSlate();
}

void UMyQuadWidget::ReleaseSlateResources(bool bReleaseChildren)
{
	Super::ReleaseSlateResources(bReleaseChildren);
	SlatePanel.Reset();
}

void UMyQuadWidget::SetCorners(FVector2D A, FVector2D B, FVector2D C, FVector2D D)
{
	CornerA = A; CornerB = B; CornerC = C; CornerD = D;
	PushToSlate();
}

void UMyQuadWidget::SetCornerA(FVector2D InA) { CornerA = InA; PushToSlate(); }
void UMyQuadWidget::SetCornerB(FVector2D InB) { CornerB = InB; PushToSlate(); }
void UMyQuadWidget::SetCornerC(FVector2D InC) { CornerC = InC; PushToSlate(); }
void UMyQuadWidget::SetCornerD(FVector2D InD) { CornerD = InD; PushToSlate(); }

void UMyQuadWidget::PushToSlate()
{
	if (SlatePanel.IsValid())
	{
		SlatePanel->SetNormQuad(BuildNormQuad());
		SlatePanel->SetEditorOutline(EditorOutlineThickness, EditorOutlineColor);
		SlatePanel->SetChildRef(GetContent() ? GetContent()->TakeWidget() : SNullWidget::NullWidget);
		InvalidateLayoutAndVolatility();
	}
}

TArray<FVector2D> UMyQuadWidget::BuildNormQuad() const
{
	TArray<FVector2D> Q; Q.Reserve(4);
	Q.Add(CornerA); Q.Add(CornerB); Q.Add(CornerC); Q.Add(CornerD);
	return Q;
}


// ----- Slate -----
void SMyQuadPanel::Construct(const FArguments& InArgs)
{
	Norm  = InArgs._NormQuad;
	Thick = InArgs._EditorOutlineThickness;
	Col   = InArgs._EditorOutlineColor;
	OnPressedDelegate  = InArgs._OnPressed;
	OnReleasedDelegate = InArgs._OnReleased;
	OnClickedDelegate  = InArgs._OnClicked;
	OnEnteredDelegate  = InArgs._OnEntered;
	OnLeftDelegate     = InArgs._OnLeft;
	ChildSlot[ InArgs._Content.Widget ];
}

void SMyQuadPanel::SetNormQuad(const TArray<FVector2D>& In)
{
	Norm = In;
	Invalidate(EInvalidateWidget::LayoutAndVolatility);
}

void SMyQuadPanel::SetEditorOutline(float Thickness, FLinearColor Color)
{
	Thick = Thickness; Col = Color;
	Invalidate(EInvalidateWidget::PaintAndVolatility);
}

void SMyQuadPanel::SetChildRef(const TSharedRef<SWidget>& InChild)
{
	ChildSlot.AttachWidget(InChild);
}

TSharedPtr<FVirtualPointerPosition> SMyQuadPanel::TranslateMouseCoordinateForCustomHitTesting(
	const FGeometry& Geo, const FVector2D& AbsolutePos) const
{
	const FVector2D Local = Geo.AbsoluteToLocal(AbsolutePos);
	const bool bInside = PointInQuad(Local, ToLocal(Geo));
	if (bInside)
	{
		return MakeShared<FVirtualPointerPosition>(AbsolutePos, Local);
	}
	else
	{
		return nullptr;
	}
}

void SMyQuadPanel::OnMouseEnter(const FGeometry& Geo, const FPointerEvent& Ev)
{
	const FVector2D L = Geo.AbsoluteToLocal(Ev.GetScreenSpacePosition());
	const bool bInside = PointInQuad(L, ToLocal(Geo));
	if (bInside && !bHoverInside)
	{
		bHoverInside = true;
		if (OnEnteredDelegate.IsBound()) OnEnteredDelegate.Execute();
	}
	SCompoundWidget::OnMouseEnter(Geo, Ev);
}

void SMyQuadPanel::OnMouseLeave(const FPointerEvent& Ev)
{
	if (bHoverInside)
	{
		bHoverInside = false;
		if (OnLeftDelegate.IsBound()) OnLeftDelegate.Execute();
	}
	SCompoundWidget::OnMouseLeave(Ev);
}

FReply SMyQuadPanel::OnMouseMove(const FGeometry& Geo, const FPointerEvent& Ev)
{
	const FVector2D L = Geo.AbsoluteToLocal(Ev.GetScreenSpacePosition());
	const bool bInside = PointInQuad(L, ToLocal(Geo));
	if (bInside && !bHoverInside)
	{
		bHoverInside = true;
		if (OnEnteredDelegate.IsBound()) OnEnteredDelegate.Execute();
	}
	else if (!bInside && bHoverInside)
	{
		bHoverInside = false;
		if (OnLeftDelegate.IsBound()) OnLeftDelegate.Execute();
	}
	return SCompoundWidget::OnMouseMove(Geo, Ev);
}

FReply SMyQuadPanel::OnMouseButtonDown(const FGeometry& Geo, const FPointerEvent& Ev)
{
	if (Ev.GetEffectingButton() != EKeys::LeftMouseButton)
		return SCompoundWidget::OnMouseButtonDown(Geo, Ev);

	const FVector2D L = Geo.AbsoluteToLocal(Ev.GetScreenSpacePosition());
	const bool bInside = PointInQuad(L, ToLocal(Geo));
	bPressedInside = bInside;

	if (bInside)
	{
		if (OnPressedDelegate.IsBound()) OnPressedDelegate.Execute();
		return SCompoundWidget::OnMouseButtonDown(Geo, Ev);
	}
	return FReply::Unhandled();
}

FReply SMyQuadPanel::OnMouseButtonUp(const FGeometry& Geo, const FPointerEvent& Ev)
{
	const FVector2D L = Geo.AbsoluteToLocal(Ev.GetScreenSpacePosition());
	const bool bInside = PointInQuad(L, ToLocal(Geo));

	if (bInside)
	{
		if (OnReleasedDelegate.IsBound()) OnReleasedDelegate.Execute();
		if (bPressedInside && OnClickedDelegate.IsBound()) OnClickedDelegate.Execute();
	}
	bPressedInside = false;

	return bInside ? SCompoundWidget::OnMouseButtonUp(Geo, Ev) : FReply::Unhandled();
}

int32 SMyQuadPanel::OnPaint(const FPaintArgs& Args, const FGeometry& Geo, const FSlateRect& Clip,
                            FSlateWindowElementList& Out, int32 Layer, const FWidgetStyle& Style,
                            bool bParentEnabled) const
{
#if WITH_EDITOR
	if (Thick > 0.0f && Norm.Num() == 4)
	{
		TArray<FVector2D> L = ToLocal(Geo);
		const FVector2D First = L.Num() ? L[0] : FVector2D::ZeroVector;
		L.Add(First);
		FSlateDrawElement::MakeLines(
			Out, Layer, Geo.ToPaintGeometry(), L, ESlateDrawEffect::None,
			(Col * Style.GetColorAndOpacityTint()).ToFColor(true), true, Thick);
		Layer++;
	}
#endif
	return SCompoundWidget::OnPaint(Args, Geo, Clip, Out, Layer, Style, bParentEnabled);
}

TArray<FVector2D> SMyQuadPanel::ToLocal(const FGeometry& Geo) const
{
	// map normalized corners to the ChildSlot content rect
	const FMargin Pad = ChildSlot.GetPadding();
	const FVector2D Full = Geo.GetLocalSize();
	const FVector2D Size(
		FMath::Max(0.f, Full.X - (Pad.Left + Pad.Right)),
		FMath::Max(0.f, Full.Y - (Pad.Top  + Pad.Bottom))
	);
	const FVector2D Origin(Pad.Left, Pad.Top);

	TArray<FVector2D> L; L.Reserve(4);
	for (const FVector2D& N : Norm)
	{
		const FVector2D Nc = Clamp01(N);
		L.Add( Origin + FVector2D(Nc.X * Size.X, Nc.Y * Size.Y) );
	}
	return L;
}

bool SMyQuadPanel::SameSide2D(const FVector2D& P, const FVector2D& A, const FVector2D& B, const FVector2D& Ref)
{
	const FVector2D AB = B - A;
	const float z1 = AB.X*(P.Y - A.Y) - AB.Y*(P.X - A.X);
	const float z2 = AB.X*(Ref.Y - A.Y) - AB.Y*(Ref.X - A.X);
	return z1 * z2 >= 0.f;
}

bool SMyQuadPanel::PointInQuad(const FVector2D& P, const TArray<FVector2D>& Q)
{
	const FVector2D A = Q[0], B = Q[1], C = Q[2], D = Q[3];
	return SameSide2D(P, A, B, C)
		&& SameSide2D(P, B, C, D)
		&& SameSide2D(P, C, D, A)
		&& SameSide2D(P, D, A, B);
}

3- B : Configuring Quad Widget : After this we should be able to access our custom quad widget from UMG

It’s edges can be defined from details or from bp function directly.

3-C : Building atomic Pie Buttons : Added a canvas panel and My Quad Widget. Set it’s size to 256x256, On It’s child lives an overlay as wrapper MyButtonContainer which simply holds our button elements: some backgrounds with our material from step 2 and one icon. If you don’t want to use custom QuadWrapperWidget you can simply add a size box or any other element to define sizings of your button.

3-D: Building atomic Pie Buttons Logic : We have couple of functions over here and 2 events.

SetButtonState(): Compare current state is different → Then create change state and create an event OnButtonStateChanged.

CreateDynamicButton() : Creates our material as instance and adds to background widget elements. Also holds material reference since we will use it as indicator later on.

PrepeareButton() : Simply we pass angle of slice value to our materials and set an Icon. Additionally we make the widget rotations for radial menu in this element with incoming angle. Icon is rotated negatively to other side to have a downward facing icon alignment at all times.
An important point over here is SetCorners() for QuadWidget. As seen A and D corners (Top Left and BottomLeft) are set to (0, 0.5), on the other hand B and C corners are X =1 and Y is set to incoming angles Tangent/2 subtracted from 0.5 (middle point). This will sit on the edges when angle variable is introduced from parent widget.

4-A: Creating Parent Widget as Radial_WGT :
Radial hierarcy is as follows.

4-B: Radial_WGT logic

We get our children in buttons container ,cast to its class and save them as array.
Calculate our angle desired for button count then prepeare buttons.

OnConstruction() We run function PrepeareMouse() which sets the mouse button to be center for sure. Additionally bind to our buttons Interacted Event.

CreateIndicator() can be anything for now it creates a niagara system in the world with the buttons icon passed to niagara system, nothing much.

SetHoverButton() → Deselect all buttons and set state hover current button index
DeselectAllButtons()->Set all buttons state normal.
RemoveRadialMenu()-> Calls event for exit on parent widget , try Interact() then remove destroy()
TryInteract()-> If there is a button currently we are howering as valid, calls event as OnButtonInteracted. This can be done in button level if wanted as well.

We have 2 major functions as
MakeCardinalSelection() : Which simply gets a vector of input position, compares with a deadzone value, if greater converts this to a 0-360 angle and derives index of the button which passed as incoming index.

Additionally runs a UpdateProgressBar() which simply controls the materials progress bar variables to indicate confirmation call to action. If distance radius from center reaches outer bounds of the radial menu we consider it %100

4-C Radial_WGT input and Interaction Design

Below two functions are simply listeners for our input button release for gamepad and mouse.

Override OnMouseButtonUp and check if the up button is our IA_RadialMenu compare and run function RemoveRadialMenu()

Similarly OnKeyUp check if the up button is our IA_RadialMenu compare and run function RemoveRadialMenu()

For controlling our cardinal selection through the widget we use functions below as override OnMouseMove() and OnAnalogValueChanged() override both and do as follows.

for getting radius use macro below, its a hypotenuse calculation nothing much.

OnMouseMove() we get mouse pos reletive to screen center and it’s radius and pass it to MakeCardinalSelection(). Additionally we compare if this radius reaches the edge of menu we RemoveRadialMenu() which before removal tries interact if there is a valid hover.

Similarly OnAnalogValueChanged(), we get X and Y values of thumbstick and globally save them at the end make MakeCardinalSelection(). We don’t remove it here since analog sticks are not very comfortable with edge control still can be extended for further interaction behaviour.

5- Binding all together.

We just need to spawn widget and set inputs to UI only, widgets should take care of the rest since we added input down listeners in widget which will remove the widget in the end running the OnRadialMenuExit event. Simply we bind to that in pawn to listen removal of widget so we can give back controls to game. Depending on the setup can be different for sure.

Results
1

Let me know your questions and feedback if any.