diff --git a/Config/DefaultGame.ini b/Config/DefaultGame.ini index 0ae61bc5..9dbbcc5a 100644 --- a/Config/DefaultGame.ini +++ b/Config/DefaultGame.ini @@ -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 + diff --git a/Content/Blueprints/Player/BP_Player.uasset b/Content/Blueprints/Player/BP_Player.uasset index c223250f..0ef63785 100644 --- a/Content/Blueprints/Player/BP_Player.uasset +++ b/Content/Blueprints/Player/BP_Player.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9276c0b6a148953571a4523c9eedc84479272e626476df8bf85819db46469c1e -size 50781 +oid sha256:7f8e81f56a4534293c7bea741bc4fabe932c6df4f47539c6e6380d9935e08f4e +size 53672 diff --git a/Content/Input/Actions/IA_Equipment.uasset b/Content/Input/Actions/IA_Equipment.uasset new file mode 100644 index 00000000..cbdf09f5 --- /dev/null +++ b/Content/Input/Actions/IA_Equipment.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0963f2ed890d5dfd079ae62853505c68584125670b5bba0771394246c63f44ae +size 1175 diff --git a/Content/Input/Actions/IA_Inventory.uasset b/Content/Input/Actions/IA_Inventory.uasset deleted file mode 100644 index 95ba7a80..00000000 --- a/Content/Input/Actions/IA_Inventory.uasset +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:7ecdd033a90ee1351952d3b81a45c5befc9e8b75af62dd1b9afb099a3217d6cb -size 1167 diff --git a/Content/Input/IMC_Game.uasset b/Content/Input/IMC_Game.uasset index f3335e77..ca5a07de 100644 --- a/Content/Input/IMC_Game.uasset +++ b/Content/Input/IMC_Game.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:54e4838a9f3c71ae8aa93f64ef9ae6bb946aa6bbdcc19a755f8c300c6fb4b991 -size 10438 +oid sha256:74dfcfccf98be6117cb32acd898c222d15080cee43d804592c69939bc2cbb279 +size 10589 diff --git a/Content/Test/Maps/TestLevel.umap b/Content/Test/Maps/TestLevel.umap index 85c2b37d..1bb17095 100644 --- a/Content/Test/Maps/TestLevel.umap +++ b/Content/Test/Maps/TestLevel.umap @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:282ddac28cd237a9b454002e10b195782cc72223ced1042b30ea736c91bbed4c -size 47073 +oid sha256:014e294219a11647ac0d0b4641c74ee0f9a9e7a354ee9183b621a07c838c9c89 +size 49583 diff --git a/Content/UI/RadialMenu/MI_RadialMenu.uasset b/Content/UI/RadialMenu/MI_RadialMenu.uasset new file mode 100644 index 00000000..c74cbac4 --- /dev/null +++ b/Content/UI/RadialMenu/MI_RadialMenu.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:15f0b171eb6e0fab89bc94a0043b575ce1ae9da6a075dfc7771f3f2519bfb987 +size 9696 diff --git a/Content/UI/RadialMenu/M_RadialMenu.uasset b/Content/UI/RadialMenu/M_RadialMenu.uasset new file mode 100644 index 00000000..69c1d4f4 --- /dev/null +++ b/Content/UI/RadialMenu/M_RadialMenu.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6c5b00a61ade7a7a808a098113d149b3bc9aca562aab81b2ffa7a068f37eca58 +size 25060 diff --git a/Content/UI/RadialMenu/WBP_RadialMenu.uasset b/Content/UI/RadialMenu/WBP_RadialMenu.uasset new file mode 100644 index 00000000..79a47e0f --- /dev/null +++ b/Content/UI/RadialMenu/WBP_RadialMenu.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2426e58602270a395874d8a4ce31321282ca6ccca901c0a09ddbd6e288e60267 +size 24939 diff --git a/Content/UI/RadialMenu/WBP_RadialSlice.uasset b/Content/UI/RadialMenu/WBP_RadialSlice.uasset new file mode 100644 index 00000000..9c56dfca --- /dev/null +++ b/Content/UI/RadialMenu/WBP_RadialSlice.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:228e5755e953b465966c79d2b29997d0acd0d60c0d362519511824b2a8164ecc +size 31321 diff --git a/Source/NakedDesire/Player/NakedDesireCharacter.cpp b/Source/NakedDesire/Player/NakedDesireCharacter.cpp index 8dfa4300..64dd4e16 100644 --- a/Source/NakedDesire/Player/NakedDesireCharacter.cpp +++ b/Source/NakedDesire/Player/NakedDesireCharacter.cpp @@ -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("Stats Manager"); MissionsManager = CreateDefaultSubobject("Missions Manager"); InteractionManager = CreateDefaultSubobject("Interaction Manager"); + RadialMenuController = CreateDefaultSubobject(TEXT("Radial Menu Controller")); NipplesMeshComponent = CreateDefaultSubobject("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" diff --git a/Source/NakedDesire/Player/NakedDesireCharacter.h b/Source/NakedDesire/Player/NakedDesireCharacter.h index 45f98012..addff674 100644 --- a/Source/NakedDesire/Player/NakedDesireCharacter.h +++ b/Source/NakedDesire/Player/NakedDesireCharacter.h @@ -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 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(); diff --git a/Source/NakedDesire/UI/RadialMenu/RadialMenuController.cpp b/Source/NakedDesire/UI/RadialMenu/RadialMenuController.cpp new file mode 100644 index 00000000..2b4ec3f6 --- /dev/null +++ b/Source/NakedDesire/UI/RadialMenu/RadialMenuController.cpp @@ -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( + 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(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( + 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( + 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); +} \ No newline at end of file diff --git a/Source/NakedDesire/UI/RadialMenu/RadialMenuController.h b/Source/NakedDesire/UI/RadialMenu/RadialMenuController.h new file mode 100644 index 00000000..42ba2bb1 --- /dev/null +++ b/Source/NakedDesire/UI/RadialMenu/RadialMenuController.h @@ -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 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 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 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 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; +}; \ No newline at end of file diff --git a/Source/NakedDesire/UI/RadialMenu/RadialMenuWidget.cpp b/Source/NakedDesire/UI/RadialMenu/RadialMenuWidget.cpp new file mode 100644 index 00000000..253ea6e8 --- /dev/null +++ b/Source/NakedDesire/UI/RadialMenu/RadialMenuWidget.cpp @@ -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& 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(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& 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(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(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); + } + } +} \ No newline at end of file diff --git a/Source/NakedDesire/UI/RadialMenu/RadialMenuWidget.h b/Source/NakedDesire/UI/RadialMenu/RadialMenuWidget.h new file mode 100644 index 00000000..5a99454b --- /dev/null +++ b/Source/NakedDesire/UI/RadialMenu/RadialMenuWidget.h @@ -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& 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 SliceCanvas = nullptr; + + UPROPERTY(EditDefaultsOnly, Category = "Radial Menu") + TSubclassOf SliceWidgetClass; + + UPROPERTY(EditDefaultsOnly, Category = "Radial Menu") + TObjectPtr 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& InEntries); + + UPROPERTY(Transient) + TWeakObjectPtr OwningController; + + UPROPERTY(Transient) + TArray> Slices; + + // Handles kept so we can unregister on deactivation. + FUIActionBindingHandle ConfirmHandle; + FUIActionBindingHandle BackHandle; + + int32 HoveredIndex = -1; +}; \ No newline at end of file diff --git a/Source/NakedDesire/UI/RadialMenu/RadialSliceWidget.cpp b/Source/NakedDesire/UI/RadialMenu/RadialSliceWidget.cpp new file mode 100644 index 00000000..b57d7e6b --- /dev/null +++ b/Source/NakedDesire/UI/RadialMenu/RadialSliceWidget.cpp @@ -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(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)); + } +} \ No newline at end of file diff --git a/Source/NakedDesire/UI/RadialMenu/RadialSliceWidget.h b/Source/NakedDesire/UI/RadialMenu/RadialSliceWidget.h new file mode 100644 index 00000000..5ad6cb5f --- /dev/null +++ b/Source/NakedDesire/UI/RadialMenu/RadialSliceWidget.h @@ -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 WedgeImage = nullptr; + + UPROPERTY(meta = (BindWidget)) + TObjectPtr 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 WedgeMID = nullptr; + + bool bSliceEnabled = true; + float CurrentHighlight = 0.f; +}; \ No newline at end of file