Started radial menu setup

This commit is contained in:
2026-05-25 22:56:19 +03:00
parent 37d19b0817
commit 48951fa305
18 changed files with 761 additions and 9 deletions
@@ -17,6 +17,7 @@
#include "NakedDesire/Clothing/ClothingItemInstance.h"
#include "NakedDesire/Global/Constants.h"
#include "NakedDesire/Global/NakedDesireUserSettings.h"
#include "NakedDesire/UI/RadialMenu/RadialMenuController.h"
#include "Perception/AIPerceptionStimuliSourceComponent.h"
#include "Perception/AISense_Sight.h"
@@ -36,6 +37,7 @@ ANakedDesireCharacter::ANakedDesireCharacter()
StatsManager = CreateDefaultSubobject<UStatsManager>("Stats Manager");
MissionsManager = CreateDefaultSubobject<UMissionsManager>("Missions Manager");
InteractionManager = CreateDefaultSubobject<UInteractionManager>("Interaction Manager");
RadialMenuController = CreateDefaultSubobject<URadialMenuController>(TEXT("Radial Menu Controller"));
NipplesMeshComponent = CreateDefaultSubobject<USkeletalMeshComponent>("Nipples");
NipplesMeshComponent->SetupAttachment(GetMesh());
@@ -131,6 +133,7 @@ void ANakedDesireCharacter::SetupPlayerInputComponent(UInputComponent* PlayerInp
EnhancedInputComponent->BindAction(RunAction, ETriggerEvent::Started, this, &ANakedDesireCharacter::OnRunPress);
EnhancedInputComponent->BindAction(RunAction, ETriggerEvent::Completed, this, &ANakedDesireCharacter::OnRunRelease);
EnhancedInputComponent->BindAction(CrouchAction, ETriggerEvent::Completed, this, &ANakedDesireCharacter::OnCrouchToggle);
EnhancedInputComponent->BindAction(EquipmentAction, ETriggerEvent::Started, this, &ANakedDesireCharacter::OnEquipmentPress);
}
}
@@ -363,6 +366,11 @@ void ANakedDesireCharacter::OnCrouchToggle(const FInputActionValue& Value)
}
}
void ANakedDesireCharacter::OnEquipmentPress(const FInputActionValue& Value)
{
RadialMenuController->OpenMenu();
}
void ANakedDesireCharacter::SetupClothingSlots()
{
#define LOCTEXT_NAMESPACE "ClothingSlots"
@@ -9,9 +9,11 @@
#include "NakedDesire/Global/Gait.h"
#include "NakedDesire/Global/NakedDesireUserSettings.h"
#include "NakedDesire/Global/Stance.h"
#include "NakedDesire/UI/RadialMenu/RadialMenuController.h"
#include "Perception/AISightTargetInterface.h"
#include "NakedDesireCharacter.generated.h"
class URadialMenuController;
class UAIPerceptionStimuliSourceComponent;
class UClothingList;
struct FClothingSlotData;
@@ -49,6 +51,9 @@ public:
UPROPERTY(EditDefaultsOnly, Category = "Input")
UInputAction* CrouchAction;
UPROPERTY(EditDefaultsOnly, Category = "Input")
UInputAction* EquipmentAction;
// Clothing
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Clothing")
@@ -133,6 +138,9 @@ public:
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
UAIPerceptionStimuliSourceComponent* StimuliSourceComponent;
UPROPERTY(EditDefaultsOnly)
TObjectPtr<URadialMenuController> RadialMenuController;
// Variables
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
float WalkSpeed = 250.0f;
@@ -191,6 +199,7 @@ private:
void OnRunPress(const FInputActionValue& Value);
void OnRunRelease(const FInputActionValue& Value);
void OnCrouchToggle(const FInputActionValue& Value);
void OnEquipmentPress(const FInputActionValue& Value);
void SetupClothingSlots();
@@ -0,0 +1,225 @@
// RadialMenuController.cpp
#include "RadialMenuController.h"
#include "RadialMenuWidget.h"
#include "GameFramework/PlayerController.h"
#include "Kismet/GameplayStatics.h"
URadialMenuController::URadialMenuController()
{
// We only tick while the menu is open; we toggle this in Open/Close so the
// component costs nothing the rest of the time.
PrimaryComponentTick.bCanEverTick = true;
PrimaryComponentTick.bStartWithTickEnabled = false;
}
void URadialMenuController::OpenMenu()
{
if (bIsOpen || Entries.Num() == 0)
{
return;
}
APlayerController* PC = Cast<APlayerController>(
UGameplayStatics::GetPlayerController(this, 0));
if (!PC || !WidgetClass)
{
return;
}
bIsOpen = true;
HoveredIndex = -1;
OpenRealTimeSeconds = FApp::GetCurrentTime(); // undilated wall-ish time
// Dilate time. CustomTimeDilation on the actor would only affect us; we want
// the whole world to slow, so we use global dilation and cache the old value.
CachedTimeDilation = UGameplayStatics::GetGlobalTimeDilation(this);
SetTimeDilation(OpenTimeDilation);
// Show + center the cursor so the player has a clear anchor to aim from.
PC->bShowMouseCursor = true;
int32 ViewX, ViewY;
PC->GetViewportSize(ViewX, ViewY);
PC->SetMouseLocation(ViewX / 2, ViewY / 2);
ActiveWidget = CreateWidget<URadialMenuWidget>(PC, WidgetClass);
if (ActiveWidget)
{
ActiveWidget->InitializeFromController(this, Entries);
// CommonUI activatable widgets belong on an activatable stack/layer so
// the Back action and activation lifecycle work. If you have a layer set
// up, push there instead of the viewport (see PushToLayer below). For a
// standalone setup we add to viewport and activate manually.
ActiveWidget->AddToViewport(100); // high Z so it sits above the HUD
ActiveWidget->ActivateWidget(); // triggers NativeOnActivated -> binds input
}
SetComponentTickEnabled(true);
}
void URadialMenuController::CloseAndConfirm()
{
if (!bIsOpen)
{
return;
}
// Tap-to-open guard: if a confirm arrives within the guard window of opening,
// treat it as noise from the opening press rather than a real selection.
// We swallow it entirely (menu stays open) so the player can still choose.
if (OpenInputGuardSeconds > 0.f
&& (FApp::GetCurrentTime() - OpenRealTimeSeconds) < OpenInputGuardSeconds)
{
return;
}
const int32 Confirmed =
(HoveredIndex >= 0 && Entries.IsValidIndex(HoveredIndex)
&& Entries[HoveredIndex].bEnabled)
? HoveredIndex
: -1;
// Tear down first so listeners see a clean closed state when they react.
const int32 IndexToBroadcast = Confirmed;
CloseAndCancel();
OnSelectionConfirmed.Broadcast(IndexToBroadcast);
}
void URadialMenuController::CloseAndCancel()
{
if (!bIsOpen)
{
return;
}
bIsOpen = false;
SetComponentTickEnabled(false);
SetTimeDilation(CachedTimeDilation);
if (APlayerController* PC = Cast<APlayerController>(
UGameplayStatics::GetPlayerController(this, 0)))
{
PC->bShowMouseCursor = false;
}
if (ActiveWidget)
{
// DeactivateWidget triggers NativeOnDeactivated, which unregisters the
// Confirm/Back bindings. Then remove it from the viewport/layer.
ActiveWidget->DeactivateWidget();
ActiveWidget->RemoveFromParent();
ActiveWidget = nullptr;
}
HoveredIndex = -1;
}
void URadialMenuController::TickComponent(float DeltaTime, ELevelTick TickType,
FActorComponentTickFunction* ThisTickFunction)
{
Super::TickComponent(DeltaTime, TickType, ThisTickFunction);
if (bIsOpen)
{
UpdateHoverFromInput();
}
}
void URadialMenuController::UpdateHoverFromInput()
{
APlayerController* PC = Cast<APlayerController>(
UGameplayStatics::GetPlayerController(this, 0));
if (!PC)
{
return;
}
// --- Gamepad right stick takes priority if it's pushed past the dead zone --
float StickX = 0.f, StickY = 0.f;
PC->GetInputAnalogStickState(EControllerAnalogStick::CAS_RightStick, StickX, StickY);
const float StickMag = FVector2D(StickX, StickY).Size();
float DirX, DirY;
bool bHaveDirection = false;
if (StickMag >= SelectionDeadZone)
{
DirX = StickX;
DirY = StickY;
bHaveDirection = true;
}
else
{
// --- Fall back to mouse: direction from screen center to cursor -------
float MouseX, MouseY;
if (PC->GetMousePosition(MouseX, MouseY))
{
int32 ViewX, ViewY;
PC->GetViewportSize(ViewX, ViewY);
const FVector2D Center(ViewX * 0.5f, ViewY * 0.5f);
const FVector2D Offset = FVector2D(MouseX, MouseY) - Center;
// Dead zone in pixels scaled to viewport so it's resolution-stable.
const float DeadPx = SelectionDeadZone * (ViewY * 0.25f);
if (Offset.Size() >= DeadPx)
{
DirX = Offset.X;
DirY = -Offset.Y; // screen Y is down; flip so up = +Y
bHaveDirection = true;
}
else
{
DirX = DirY = 0.f;
}
}
else
{
DirX = DirY = 0.f;
}
}
if (!bHaveDirection)
{
// Inside dead zone: keep whatever was hovered (no jitter, no deselect).
return;
}
// Angle clockwise from straight up (12 o'clock), in [0,360).
// atan2(x, y) gives angle from +Y axis turning toward +X = clockwise. Nice.
float AngleDeg = FMath::RadiansToDegrees(FMath::Atan2(DirX, DirY));
if (AngleDeg < 0.f)
{
AngleDeg += 360.f;
}
const int32 NewIndex = AngleToSegment(AngleDeg);
if (NewIndex != HoveredIndex)
{
HoveredIndex = NewIndex;
if (ActiveWidget)
{
ActiveWidget->SetHoveredSegment(HoveredIndex);
}
}
}
int32 URadialMenuController::AngleToSegment(float AngleDegrees) const
{
const int32 Count = Entries.Num();
if (Count <= 0)
{
return -1;
}
const float SliceSize = 360.f / Count;
// Offset by half a slice so that slice 0 is *centered* on 12 o'clock
// rather than starting there — matches the GTA layout.
const float Shifted = FMath::Fmod(AngleDegrees + SliceSize * 0.5f, 360.f);
return FMath::Clamp(FMath::FloorToInt(Shifted / SliceSize), 0, Count - 1);
}
void URadialMenuController::SetTimeDilation(float Scale)
{
UGameplayStatics::SetGlobalTimeDilation(this, Scale);
}
@@ -0,0 +1,126 @@
// RadialMenuController.h
// Local-only radial weapon selector for the Naked Desire project.
// No replication, no RPCs: this is single-player UI logic.
#pragma once
#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "RadialMenuController.generated.h"
class URadialMenuWidget;
/**
* One entry in the radial menu. Swap this out for your real item type later
* (e.g. a soft pointer to a UPrimaryDataAsset) — the controller only ever
* reads DisplayName + Icon to build slices, so the rest of the menu is agnostic.
*/
USTRUCT(BlueprintType)
struct FRadialMenuEntry
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Radial Menu")
FText DisplayName;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Radial Menu")
TObjectPtr<UTexture2D> Icon = nullptr;
// Optional payload tag so the owning code knows what to equip on confirm.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Radial Menu")
FName ItemId = NAME_None;
// Set false to draw the slice greyed-out (e.g. weapon not yet unlocked).
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Radial Menu")
bool bEnabled = true;
};
// Fires when the player confirms a slice. Index is into the Entries array; -1 = cancelled.
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnRadialSelectionConfirmed, int32, SelectedIndex);
UCLASS(ClassGroup = (UI), meta = (BlueprintSpawnableComponent))
class URadialMenuController : public UActorComponent
{
GENERATED_BODY()
public:
URadialMenuController();
// --- Public API ---------------------------------------------------------
// Call on "hold" pressed. Opens the menu, dilates time, shows the cursor.
UFUNCTION(BlueprintCallable, Category = "Radial Menu")
void OpenMenu();
// Call on "hold" released. Confirms the hovered slice and closes.
UFUNCTION(BlueprintCallable, Category = "Radial Menu")
void CloseAndConfirm();
// Close without selecting anything (e.g. pressed a cancel key).
UFUNCTION(BlueprintCallable, Category = "Radial Menu")
void CloseAndCancel();
UFUNCTION(BlueprintPure, Category = "Radial Menu")
bool IsOpen() const { return bIsOpen; }
UFUNCTION(BlueprintPure, Category = "Radial Menu")
int32 GetHoveredIndex() const { return HoveredIndex; }
// The data the menu draws. Populate from wherever you like (data table,
// inventory component, hardcoded list) before calling OpenMenu().
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Radial Menu")
TArray<FRadialMenuEntry> Entries;
UPROPERTY(BlueprintAssignable, Category = "Radial Menu")
FOnRadialSelectionConfirmed OnSelectionConfirmed;
// --- Tunables -----------------------------------------------------------
// How long after opening (real seconds) confirms are ignored, so the tap
// that opens the wheel can't immediately select. ~0.15s is imperceptible
// but covers the same-frame / next-frame case. Set 0 to disable.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Radial Menu|Tuning",
meta = (ClampMin = "0.0"))
float OpenInputGuardSeconds = 0.15f;
// Time scale while the menu is open. GTA sits around 0.2; 0.0 = full pause.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Radial Menu|Tuning",
meta = (ClampMin = "0.0", ClampMax = "1.0"))
float OpenTimeDilation = 0.2f;
// Dead zone (gamepad) / radius in px (mouse) below which we keep the last
// hovered slice instead of snapping to a new one. Stops jitter near center.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Radial Menu|Tuning")
float SelectionDeadZone = 0.25f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Radial Menu|Tuning")
TSubclassOf<URadialMenuWidget> WidgetClass;
protected:
virtual void TickComponent(float DeltaTime, ELevelTick TickType,
FActorComponentTickFunction* ThisTickFunction) override;
private:
// Converts a screen-space or stick direction into a slice index.
// Angle is measured clockwise from straight up (12 o'clock), matching GTA.
int32 AngleToSegment(float AngleDegrees) const;
// Reads current input (mouse delta or right stick) and updates HoveredIndex.
void UpdateHoverFromInput();
void SetTimeDilation(float Scale);
UPROPERTY(Transient)
TObjectPtr<URadialMenuWidget> ActiveWidget = nullptr;
bool bIsOpen = false;
int32 HoveredIndex = -1;
// Real-time timestamp when the menu opened. CloseAndConfirm ignores confirms
// that arrive within OpenInputGuardSeconds, so the tap that opens the wheel
// can't also register as a selection on the same/next frame.
double OpenRealTimeSeconds = 0.0;
// Cached so we can restore exactly what the game had before we opened.
float CachedTimeDilation = 1.0f;
};
@@ -0,0 +1,145 @@
// RadialMenuWidget.cpp
#include "RadialMenuWidget.h"
#include "RadialSliceWidget.h"
#include "Components/CanvasPanel.h"
#include "Components/CanvasPanelSlot.h"
#include "Input/CommonUIInputTypes.h" // FBindUIActionArgs
void URadialMenuWidget::InitializeFromController(
URadialMenuController* InController, const TArray<FRadialMenuEntry>& InEntries)
{
OwningController = InController;
BuildSlices(InEntries);
}
void URadialMenuWidget::NativeOnActivated()
{
Super::NativeOnActivated();
SetIsFocusable(true);
// Bind Confirm. FBindUIActionArgs accepts the row handle directly.
if (!ConfirmAction.IsNull())
{
FBindUIActionArgs ConfirmArgs(
ConfirmAction,
FSimpleDelegate::CreateUObject(this, &URadialMenuWidget::HandleConfirm));
// Show in the on-screen action bar so the player sees the prompt.
ConfirmArgs.bDisplayInActionBar = true;
ConfirmHandle = RegisterUIActionBinding(ConfirmArgs);
}
if (!BackAction.IsNull())
{
FBindUIActionArgs BackArgs(
BackAction,
FSimpleDelegate::CreateUObject(this, &URadialMenuWidget::HandleBack));
BackArgs.bDisplayInActionBar = true;
BackHandle = RegisterUIActionBinding(BackArgs);
}
}
void URadialMenuWidget::NativeOnDeactivated()
{
// Clean up bindings so they don't linger if the widget is pooled/reused.
if (ConfirmHandle.IsValid())
{
ConfirmHandle.Unregister();
}
if (BackHandle.IsValid())
{
BackHandle.Unregister();
}
Super::NativeOnDeactivated();
}
UWidget* URadialMenuWidget::NativeGetDesiredFocusTarget() const
{
// Keep focus on the menu root so the bound actions receive input.
return const_cast<URadialMenuWidget*>(this);
}
void URadialMenuWidget::HandleConfirm()
{
// Route to the controller, which owns the state, time dilation, and the
// OnSelectionConfirmed broadcast. The controller also tears down the widget.
if (OwningController.IsValid())
{
OwningController->CloseAndConfirm();
}
}
void URadialMenuWidget::HandleBack()
{
if (OwningController.IsValid())
{
OwningController->CloseAndCancel();
}
}
void URadialMenuWidget::BuildSlices(const TArray<FRadialMenuEntry>& InEntries)
{
if (!SliceCanvas || !SliceWidgetClass)
{
return;
}
SliceCanvas->ClearChildren();
Slices.Reset();
HoveredIndex = -1;
const int32 Count = InEntries.Num();
if (Count == 0)
{
return;
}
const float AngleSize = 360.f / Count;
for (int32 i = 0; i < Count; ++i)
{
URadialSliceWidget* Slice =
CreateWidget<URadialSliceWidget>(GetOwningPlayer(), SliceWidgetClass);
if (!Slice)
{
continue;
}
const float StartAngle = i * AngleSize - AngleSize * 0.5f;
Slice->Setup(SliceMaterial, StartAngle, AngleSize,
InEntries[i].Icon, InEntries[i].bEnabled);
SliceCanvas->AddChild(Slice);
if (UCanvasPanelSlot* CanvasSlot = Cast<UCanvasPanelSlot>(Slice->Slot))
{
CanvasSlot->SetAnchors(FAnchors(0.5f, 0.5f));
CanvasSlot->SetAlignment(FVector2D(0.5f, 0.5f));
CanvasSlot->SetPosition(FVector2D::ZeroVector);
CanvasSlot->SetSize(FVector2D(MenuDiameter, MenuDiameter));
}
Slices.Add(Slice);
}
}
void URadialMenuWidget::SetHoveredSegment(int32 Index)
{
HoveredIndex = Index;
}
void URadialMenuWidget::NativeTick(const FGeometry& MyGeometry, float InDeltaTime)
{
Super::NativeTick(MyGeometry, InDeltaTime);
const float RealDelta = FApp::GetDeltaTime(); // undilated
for (int32 i = 0; i < Slices.Num(); ++i)
{
if (Slices[i])
{
Slices[i]->UpdateHighlight(i == HoveredIndex, RealDelta);
}
}
}
@@ -0,0 +1,88 @@
// RadialMenuWidget.h
// Container for the radial menu, now a CommonUI activatable widget. It owns the
// CommonUI input bindings (Confirm / Back) and routes them to the controller,
// which still owns all state (time dilation, hover, Entries). The widget also
// spawns one URadialSliceWidget per entry at runtime (variable slice count).
#pragma once
#include "CoreMinimal.h"
#include "CommonActivatableWidget.h"
#include "Engine/DataTable.h" // FDataTableRowHandle
#include "RadialMenuController.h" // for FRadialMenuEntry
#include "RadialMenuWidget.generated.h"
class UCanvasPanel;
class URadialSliceWidget;
struct FUIActionBindingHandle;
UCLASS(Abstract)
class URadialMenuWidget : public UCommonActivatableWidget
{
GENERATED_BODY()
public:
// Called by the controller after CreateWidget, before activation. Stores the
// owning controller so action handlers can route back to it, then builds the
// slices.
void InitializeFromController(URadialMenuController* InController,
const TArray<FRadialMenuEntry>& InEntries);
// Called when the hovered slice changes. Stored and applied each tick.
void SetHoveredSegment(int32 Index);
protected:
virtual void NativeOnActivated() override;
virtual void NativeOnDeactivated() override;
virtual void NativeTick(const FGeometry& MyGeometry, float InDeltaTime) override;
// CommonUI focus: returning the root keeps gamepad/keyboard focus on us so
// the bound actions fire while the wheel is open.
virtual UWidget* NativeGetDesiredFocusTarget() const override;
// --- CommonUI input action assets (assign in the widget BP defaults) ------
// CommonUI input action rows. These are FDataTableRowHandle pointing at your
// CommonInputActionDataTable rows (the same rows your other screens use for
// accept/back), so the on-screen action bar and key mapping stay consistent.
UPROPERTY(EditDefaultsOnly, Category = "Radial Menu|Input",
meta = (RowType = "/Script/CommonUI.CommonInputActionDataBase"))
FDataTableRowHandle ConfirmAction;
// Bound explicitly so our cancel path runs (restore time, broadcast -1)
// rather than CommonUI just popping the widget on Back.
UPROPERTY(EditDefaultsOnly, Category = "Radial Menu|Input",
meta = (RowType = "/Script/CommonUI.CommonInputActionDataBase"))
FDataTableRowHandle BackAction;
// Root canvas the slices are spawned into.
UPROPERTY(meta = (BindWidget))
TObjectPtr<UCanvasPanel> SliceCanvas = nullptr;
UPROPERTY(EditDefaultsOnly, Category = "Radial Menu")
TSubclassOf<URadialSliceWidget> SliceWidgetClass;
UPROPERTY(EditDefaultsOnly, Category = "Radial Menu")
TObjectPtr<UMaterialInterface> SliceMaterial = nullptr;
UPROPERTY(EditDefaultsOnly, Category = "Radial Menu")
float MenuDiameter = 420.f;
private:
// Action handlers, bound in NativeOnActivated.
void HandleConfirm();
void HandleBack();
void BuildSlices(const TArray<FRadialMenuEntry>& InEntries);
UPROPERTY(Transient)
TWeakObjectPtr<URadialMenuController> OwningController;
UPROPERTY(Transient)
TArray<TObjectPtr<URadialSliceWidget>> Slices;
// Handles kept so we can unregister on deactivation.
FUIActionBindingHandle ConfirmHandle;
FUIActionBindingHandle BackHandle;
int32 HoveredIndex = -1;
};
@@ -0,0 +1,74 @@
// RadialSliceWidget.cpp
#include "RadialSliceWidget.h"
#include "Components/Image.h"
#include "Components/CanvasPanelSlot.h"
#include "Materials/MaterialInstanceDynamic.h"
void URadialSliceWidget::NativeConstruct()
{
Super::NativeConstruct();
}
void URadialSliceWidget::Setup(UMaterialInterface* InSliceMaterial,
float StartAngleDeg, float AngleSizeDeg, UTexture2D* Icon, bool bEnabled)
{
bSliceEnabled = bEnabled;
if (InSliceMaterial && WedgeImage)
{
WedgeMID = UMaterialInstanceDynamic::Create(InSliceMaterial, this);
WedgeImage->SetBrushFromMaterial(WedgeMID);
// Per-slice angular span (normalized 0..1). The material draws only the
// arc between these two, so each slice is an independent wedge.
WedgeMID->SetScalarParameterValue(TEXT("StartAngle"), StartAngleDeg / 360.f);
WedgeMID->SetScalarParameterValue(TEXT("AngleSize"), AngleSizeDeg / 360.f);
WedgeMID->SetScalarParameterValue(TEXT("Highlight"), 0.f);
WedgeMID->SetScalarParameterValue(TEXT("Enabled"), bEnabled ? 1.f : 0.f);
}
if (IconImage)
{
if (Icon)
{
IconImage->SetBrushFromTexture(Icon);
}
IconImage->SetRenderOpacity(bEnabled ? 0.55f : 0.3f);
// Push the icon out along this slice's centerline so it sits inside the
// wedge rather than at the menu's center. Center angle is measured
// clockwise from 12 o'clock, matching the wedge the material draws.
const float CenterAngleDeg = StartAngleDeg + AngleSizeDeg * 0.5f;
const float CenterAngleRad = FMath::DegreesToRadians(CenterAngleDeg);
const float OffsetX = FMath::Sin(CenterAngleRad) * IconRadius;
const float OffsetY = -FMath::Cos(CenterAngleRad) * IconRadius;
if (UCanvasPanelSlot* IconSlot = Cast<UCanvasPanelSlot>(IconImage->Slot))
{
// Anchor + align to center so Position is relative to the menu's
// middle; then offset out to the slice center.
IconSlot->SetAnchors(FAnchors(0.5f, 0.5f));
IconSlot->SetAlignment(FVector2D(0.5f, 0.5f));
IconSlot->SetPosition(FVector2D(OffsetX, OffsetY));
}
}
}
void URadialSliceWidget::UpdateHighlight(bool bIsHovered, float UndilatedDelta)
{
const float Target = (bIsHovered && bSliceEnabled) ? 1.f : 0.f;
CurrentHighlight = FMath::FInterpTo(CurrentHighlight, Target,
UndilatedDelta, HighlightInterpSpeed);
if (WedgeMID)
{
WedgeMID->SetScalarParameterValue(TEXT("Highlight"), CurrentHighlight);
}
if (IconImage && bSliceEnabled)
{
// Icon brightens from its dim resting state up to full on hover.
IconImage->SetRenderOpacity(FMath::Lerp(0.55f, 1.0f, CurrentHighlight));
}
}
@@ -0,0 +1,55 @@
// RadialSliceWidget.h
// A single wedge of the radial menu. One of these exists per entry, each with
// its own dynamic material instance. It knows its own angular span and whether
// it is currently the hovered slice — nothing about its neighbours.
#pragma once
#include "CoreMinimal.h"
#include "Blueprint/UserWidget.h"
#include "RadialSliceWidget.generated.h"
class UImage;
class UMaterialInstanceDynamic;
UCLASS(Abstract)
class URadialSliceWidget : public UUserWidget
{
GENERATED_BODY()
public:
// Called once when the slice is created. StartAngle/AngleSize are degrees,
// clockwise from 12 o'clock; the material uses them to draw just this wedge.
void Setup(UMaterialInterface* InSliceMaterial, float StartAngleDeg,
float AngleSizeDeg, UTexture2D* Icon, bool bEnabled);
// Smoothly animate this slice's highlight toward target (0 = off, 1 = on).
// Called every tick by the container with the *undilated* delta.
void UpdateHighlight(bool bIsHovered, float UndilatedDelta);
protected:
virtual void NativeConstruct() override;
UPROPERTY(meta = (BindWidget))
TObjectPtr<UImage> WedgeImage = nullptr;
UPROPERTY(meta = (BindWidget))
TObjectPtr<UImage> IconImage = nullptr;
// Speed of the highlight ease. Higher = snappier.
UPROPERTY(EditDefaultsOnly, Category = "Radial Menu")
float HighlightInterpSpeed = 16.f;
// Distance in px from the menu center to the icon, placing it on the slice's
// centerline. Should sit between the material's InnerRadius and OuterRadius:
// roughly (InnerRadius + OuterRadius) * 0.5 * MenuDiameter.
UPROPERTY(EditDefaultsOnly, Category = "Radial Menu")
float IconRadius = 164.f;
private:
UPROPERTY(Transient)
TObjectPtr<UMaterialInstanceDynamic> WedgeMID = nullptr;
bool bSliceEnabled = true;
float CurrentHighlight = 0.f;
};