// 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); }