Started radial menu setup
This commit is contained in:
@@ -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