Demos/RadialWheel/RadialView.cs
using System;
using Goo;
using Goo.Animation;
using HitShapes;
using Sandbox;
using Sandbox.UI;
using static Sandbox.DemoTokens;

namespace Sandbox.RadialWheel;

// Radial action wheel: owns animator state, slot dispatcher, and event handlers; components are pure builders.
public sealed class RadialView
{
    const int   SlotCount    = 4;
    const float WheelSize    = 320f;
    const float InnerRatio   = 0.45f;
    const float LabelRadius  = 116f;
    const float LabelBox     = 72f;

    // Lower than a fully-opaque disc would need since the disc is blurred.
    const float BackdropAlpha = 0.45f;

    const float WedgeRestAlpha    = 0.92f;
    const float HighlightAlpha    = 0.18f;
    const float BackdropBlur      = 12f;       // px blur on inner disc
    const float WheelBlurAmount   = 8f;        // px blur on wheel-sized backdrop
    const float ShadowAlpha       = 0.45f;
    const float ShadowThickness   = 4f;        // px inner-rim border thickness
    const float FlashPeak         = 0.4f;      // white-blend at peak brightness flash
    const float FlashSmoothing    = 0.05f;     // ~150ms decay
    const float HotScaleFreq      = 14f;
    const float HotScaleDamping   = 0.55f;
    const float OpenSpringFreq    = 9f;
    const float OpenSpringDamping = 0.55f;
    const float CloseKick         = 8f;        // upward velocity to pop before collapsing

    const float HotScale       = 1.05f;
    const float Smoothing      = 0.06f;
    const float FontSizeRest   = 18f;
    const float FontSizeHot    = 22f;

    const float BounceFrequency = 9f;
    const float BounceDamping   = 0.4f;
    const float BounceKick      = 0.5f;
    const float ClickKick       = -6f;

    const float WedgeCornerRadius = 0.075f;  // ~12px on 320px wheel; tune to taste

    const float StartAngleDeg = 0f;  // 0 = slot 0 at 12 o'clock; rotate cw to taste.

    const float FlipDuration    = 0.5f;
    const float FlipPerspective = 800f;

    // press to toggle open/closed; closed state captures nothing so the wheel coexists with gameplay in a shared HUD. uppercase = letter key per Sandbox.Input.Keyboard.Down naming.
    const string ToggleKey = "R";

    readonly record struct SlotData( string Title, string IconSrc );
    static readonly SlotData[] Slots =
    {
        new( "mine", "SVGs/mining.svg" ),
        new( "chop", "SVGs/chop.svg" ),
        new( "fish", "SVGs/pole.svg" ),
        new( "poop", "SVGs/turd.svg" ),
    };

    static readonly Color HotOverlay = HoverGlow;

    int?  _hovered;
    int?  _selected;
    readonly int[] _slotState = new int[SlotCount];

    bool  _overHotZone;
    bool  _open;                  // starts closed; ToggleKey opens it
    bool  _togglePrev;            // edge-detect the toggle key
    SpringFloat _hotScale    = new( 1f, HotScaleFreq, HotScaleDamping );
    SpringFloat _openScale   = new( 0f, OpenSpringFreq, OpenSpringDamping );    // scales+fades wheel on open/close; starts collapsed
    SpringFloat _wheelBounce = new( 1f, BounceFrequency, BounceDamping );
    readonly SpringFloat[] _labelBounce   = new SpringFloat[SlotCount];
    readonly SmoothFloat[] _labelFontSize = new SmoothFloat[SlotCount];
    readonly SmoothFloat[] _brightFlash   = new SmoothFloat[SlotCount];
    readonly float[]       _flipT             = new float[SlotCount];
    readonly bool[]        _flipResetPending  = new bool[SlotCount];

    bool _dirty = true;
    void Invalidate() => _dirty = true;

    readonly SlotDispatcher _dispatcher;
    readonly Action<MousePanelEvent> _onMove;
    readonly Action<MousePanelEvent> _onLeave;
    readonly Action<MousePanelEvent> _onClick;

    public RadialView()
    {
        for ( int i = 0; i < SlotCount; i++ )
        {
            _labelBounce[i]   = new SpringFloat( 1f, BounceFrequency, BounceDamping );
            _labelFontSize[i] = new SmoothFloat( FontSizeRest, Smoothing );
            _brightFlash[i]   = new SmoothFloat( 0f, FlashSmoothing );  // ~150ms decay
            _flipT[i]         = -1f; // idle
        }

        _dispatcher = new SlotDispatcher( HitShape.Radial( SlotCount, innerRatio: InnerRatio, startAngleDeg: StartAngleDeg ) )
        {
            OnSlotEnter = slot =>
            {
                _hovered = slot;
                _wheelBounce.Velocity = BounceKick;
                _brightFlash[slot].Current = 1.0f;  // instant peak; decays toward Target=0
                Invalidate();
            },
            OnSlotLeave = _    => { _hovered = null; Invalidate(); },
            OnSlotClick = HandleSlotClick,
        };
        _onMove    = HandleMouseMoveOrEnter;
        _onLeave   = e => { _dispatcher.HandleMouseLeave( e ); _overHotZone = false; };
        _onClick   = HandleWheelClick;
    }

    public void Reset()
    {
        _dispatcher.Reset();
        _open = false;
        _togglePrev = false;
        _openScale.Current = _openScale.Target = 0f;
        _openScale.Velocity = 0f;
        Invalidate();
    }

    public Container Build()
    {
        // closed state: render an empty click-through placeholder so the HUD passes the cursor to gameplay; reopen is via ToggleKey, not a fullscreen catcher.
        bool fullyClosed = !_open && _openScale.Current <= 0.001f;
        if ( fullyClosed )
            return new Container { Key = "wheel-closed", PointerEvents = PointerEvents.None };

        var restColor = Ink2.WithAlpha( WedgeRestAlpha );
        Span<Color> wedgeColors = stackalloc Color[SlotCount];
        for ( int i = 0; i < SlotCount; i++ )
        {
            if ( _hovered == i )
            {
                float t = _brightFlash[i].Current * FlashPeak;
                wedgeColors[i] = Color.Lerp( HotOverlay, Color.White, t );
            }
            else
            {
                wedgeColors[i] = restColor;
            }
        }

        // Clamp openScale: SpringFloat overshoots below 0 briefly while settling after the close kick.
        float openScale = MathF.Max( 0f, _openScale.Current );
        var wheel = new Container
        {
            // keyed so the anchored slot stays all-keyed across the open/closed flip (mixed keyed/unkeyed pair warns).
            Key           = "wheel",
            Position      = PositionMode.Relative,
            Width         = WheelSize,
            Height        = WheelSize,
            PointerEvents = PointerEvents.All,
            Transform     = Goo.PanelTransform.Scale( _wheelBounce.Current * openScale ),
            Opacity       = openScale,
            OnMouseEnter  = _onMove,
            OnMouseLeave  = _onLeave,
            OnMouseMove   = _onMove,
            OnClick       = _onClick,
        };

        // Z-order: shadow -> wheel-blur -> wedges -> labels -> inner-backdrop -> center.
        wheel.Children.Add( WheelShadow.Build( ShadowThickness, ShadowAlpha ) );
        wheel.Children.Add( WheelBlur.Build( WheelBlurAmount ) );

        float innerDiameterPx = WheelSize * InnerRatio;

        Span<float> wedgeScales = stackalloc float[SlotCount];
        for ( int i = 0; i < SlotCount; i++ )
            wedgeScales[i] = (_hovered == i) ? _hotScale.Current : 1f;

        wheel.Children.Add( WedgeRing.Build(
            segments:          SlotCount,
            innerRadius:       InnerRatio,
            wedgeCornerRadius: WedgeCornerRadius,
            colors:            wedgeColors,
            wedgeScales:       wedgeScales,
            startAngleDeg:     StartAngleDeg,
            key:               "ring" ) );

        for ( int slot = 0; slot < SlotCount; slot++ )
        {
            float theta = slot * MathF.Tau / SlotCount - MathF.PI / 2f + StartAngleDeg * MathF.PI / 180f;
            float cx    = WheelSize * 0.5f + MathF.Cos( theta ) * LabelRadius;
            float cy    = WheelSize * 0.5f + MathF.Sin( theta ) * LabelRadius;

            wheel.Children.Add( SlotLabel.Build(
                slot:            slot,
                cx:              cx,
                cy:              cy,
                box:             LabelBox,
                label:           Slots[slot].Title,
                badge:           PerSlotBadge( slot, _slotState[slot] ),
                bounce:          _labelBounce[slot].Current,
                fontSize:        _labelFontSize[slot].Current,
                flipDeg:         FlipAngleDeg( slot ),
                flipPerspective: FlipPerspective,
                isHovered:       _hovered  == slot,
                isSelected:      _selected == slot ) );
        }

        Center.SlotContent? centerContent = _hovered is int h
            ? new Center.SlotContent( Slots[h].Title, Slots[h].IconSrc, IsSelected: _selected == h )
            : null;
        wheel.Children.Add( Backdrop.Build( innerDiameterPx, BackdropAlpha, BackdropBlur, HighlightAlpha ) );
        wheel.Children.Add( Center.Build( centerContent, innerDiameterPx ) );

        return wheel;
    }

    // Ease-in-out for a snappier feel at the edge-on midpoint of the flip.
    float FlipAngleDeg( int slot )
    {
        if ( _flipT[slot] < 0f ) return 0f;
        float p = MathF.Min( _flipT[slot] / FlipDuration, 1f );
        float eased = p < 0.5f
            ? 2f * p * p
            : 1f - MathF.Pow( -2f * p + 2f, 2f ) * 0.5f;
        return eased * 360f;
    }

    public bool Tick( Scene? scene, float dt )
    {
        bool toggleDown = Sandbox.Input.Keyboard.Down( ToggleKey );
        if ( toggleDown && !_togglePrev ) Toggle();
        _togglePrev = toggleDown;

        _hotScale.Target = _overHotZone ? HotScale : 1f;
        _hotScale.Update( dt );
        _wheelBounce.Update( dt );
        _openScale.Update( dt );

        // ensure a final Invalidate() when _openScale settles near 0 so the closed placeholder mounts; without this, IsSettled halts the rebuild loop while Current is ~0.0001f and the invisible wheel keeps capturing the cursor.
        if ( !_open && _openScale.IsSettled && _openScale.Current <= 0.001f )
            Invalidate();

        for ( int i = 0; i < SlotCount; i++ )
        {
            _labelFontSize[i].Target = (_hovered == i) ? FontSizeHot : FontSizeRest;
            _labelFontSize[i].Update( dt );
            _labelBounce[i].Update( dt );
            _brightFlash[i].Update( dt );

            if ( _flipT[i] >= 0f )
            {
                _flipT[i] += dt;
                if ( _flipResetPending[i] && _flipT[i] >= FlipDuration * 0.5f )
                {
                    _slotState[i] = 0;
                    if ( _selected == i ) _selected = null;
                    _flipResetPending[i] = false;
                }
                if ( _flipT[i] >= FlipDuration )
                {
                    _flipT[i] = -1f;
                }
            }
        }

        if ( NeedsRebuild() ) Invalidate();
        bool d = _dirty; _dirty = false; return d;
    }

    bool NeedsRebuild()
    {
        if ( !_hotScale.IsSettled || !_wheelBounce.IsSettled || !_openScale.IsSettled ) return true;
        for ( int i = 0; i < SlotCount; i++ )
        {
            if ( !_labelBounce[i].IsSettled || !_labelFontSize[i].IsSettled ) return true;
            if ( !_brightFlash[i].IsSettled ) return true;
            if ( _flipT[i] >= 0f ) return true;
        }
        return false;
    }

    void HandleDismiss()
    {
        _open = false;
        _openScale.Target = 0f;
        _openScale.Velocity = CloseKick;
        Invalidate();
    }

    void Toggle()
    {
        if ( _open ) HandleDismiss();
        else Open();
    }

    void Open()
    {
        if ( _open ) return;
        _open = true;
        _openScale.Target = 1f;
        _openScale.Velocity = 0f;
        Invalidate();
    }

    void HandleMouseMoveOrEnter( MousePanelEvent e )
    {
        _dispatcher.HandleMouseMove( e );
        _overHotZone = _dispatcher.CurrentSlot is not null;
    }

    void HandleWheelClick( MousePanelEvent e )
    {
        var size = e.Target?.Box.Rect.Size ?? Vector2.Zero;
        var slot = _dispatcher.Shape.Resolve( e.LocalPosition, size );
        if ( slot is int s ) HandleSlotClick( s, e );
        else if ( _open ) HandleDismiss();
    }

    void HandleSlotClick( int slot, MousePanelEvent e )
    {
        switch ( e.MouseButton )
        {
            case MouseButtons.Left:
                _slotState[slot]++;
                break;
            case MouseButtons.Right:
                // Defer the state reset to the midpoint of a Y-flip so the user
                // sees the old badge rotate to edge-on, then the new (cleared)
                // state rotate into view. Drives the "card flip" magic.
                _flipT[slot] = 0f;
                _flipResetPending[slot] = true;
                break;
            case MouseButtons.Middle:
                if ( _selected == slot ) _selected = null;
                break;
        }
        _labelBounce[slot].Velocity = ClickKick;
        Invalidate();
    }

    static string PerSlotBadge( int slot, int n )
    {
        if ( n <= 0 ) return "";
        return slot switch
        {
            0 => $"x{n}",
            1 => (n % 3) switch { 1 => "sword",  2 => "shield", _ => "bow" },
            2 => (n % 3) switch { 1 => "iron",   2 => "gold",   _ => "silver" },
            3 => $"stink x{n}",
            _ => "",
        };
    }
}