Started radial menu setup
This commit is contained in:
@@ -127,3 +127,13 @@ bSkipMovies=False
|
||||
bRetainStagedDirectory=False
|
||||
CustomStageCopyHandler=
|
||||
|
||||
[CommonInputPlatformSettings_Mac CommonInputPlatformSettings]
|
||||
DefaultInputType=MouseAndKeyboard
|
||||
bSupportsMouseAndKeyboard=True
|
||||
bSupportsTouch=False
|
||||
bSupportsGamepad=True
|
||||
DefaultGamepadName=Generic
|
||||
bCanChangeGamepadType=True
|
||||
+ControllerData=/Game/Input/GamepadControllerData.GamepadControllerData_C
|
||||
+ControllerData=/Game/Input/KeyboardControllerData.KeyboardControllerData_C
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user